@shellus/way 0.6.11 → 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
 
@@ -168,6 +171,8 @@ way restore data --host old-host --target /tmp/restore --dry-run # 从指定 ho
168
171
  way restore data --target /tmp/restore --delete # 删除目标中快照不存在的文件
169
172
  way gc # 按 retention 策略清理旧快照
170
173
  way gc --dry-run # 模拟清理(不实际删除)
174
+ way replicate # 执行全部仓库复制任务
175
+ way replicate local-to-s3 --init # 首次初始化目标仓库并执行复制
171
176
 
172
177
  # daemon 模式(推荐)
173
178
  way daemon # 启动常驻进程,按配置定时执行
@@ -205,6 +210,8 @@ graph LR
205
210
  C --> D[node-cron 定时触发]
206
211
  D --> E[执行 restic backup]
207
212
  E --> F[推送 Uptime Kuma]
213
+ D --> K[选择各项目最新快照]
214
+ K --> L[restic copy 到远程仓库]
208
215
 
209
216
  G[systemd service] --> H[启动 daemon]
210
217
  H --> I[进程崩溃自动重启]
@@ -237,7 +244,9 @@ WAY_DIR=/path/to/config way restic snapshots
237
244
  - **projects.*.hooks**: 项目级备份钩子,支持 `before_backup` 和 `after_backup`
238
245
  - **uptime_kuma.push_url**: 全局 Uptime Kuma Push 地址,作为项目未配置通知地址时的回退
239
246
  - **projects.*.uptime_kuma.push_url**: 项目级 Uptime Kuma Push 地址,优先于全局地址
247
+ - **replications**: 仓库复制任务,包含源仓库、目标仓库、调度和目标仓库独立保留策略
240
248
  - **maintenance**: 维护任务配置(prune、check)
249
+ - **maintenance.prune.uptime_kuma.push_url**: daemon 定时 prune 的 Uptime Kuma Push 地址,未配置时回退到全局地址;prune 成功推送 UP,失败推送带原因的 DOWN
241
250
  - **global_excludes**: 全局排除规则
242
251
 
243
252
  `defaults.retention` 支持两种互斥模式:
@@ -372,6 +381,27 @@ daemon 的调度心跳最多容忍 5 秒延迟;事件循环短暂阻塞后仍
372
381
  | `"*/30 * * * *"` | 分钟间隔 | 每 30 分钟 |
373
382
  | `false` | 禁用自动调度 | 只手动备份 |
374
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
+
375
405
  #### 排除规则通配符语法
376
406
 
377
407
  restic 使用 Go 的 filepath.Match 语法:
@@ -396,6 +426,18 @@ repositories:
396
426
  path: /backup/repo
397
427
  credentials:
398
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
399
441
  ```
400
442
 
401
443
  建议设置文件权限:`chmod 600 ~/.way/repositories.yaml`
package/dist/cli.js CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
- import fs7 from "fs";
6
- import path8 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";
@@ -93,19 +93,20 @@ function resolveResticBin(options = {}) {
93
93
  }
94
94
 
95
95
  // src/core/restic.ts
96
- function buildResticEnv(repo) {
97
- const env = {};
96
+ function buildRepositoryLocation(repo) {
98
97
  switch (repo.type) {
99
98
  case "s3":
100
- env.RESTIC_REPOSITORY = `s3:https://${repo.endpoint}/${repo.bucket}`;
101
- break;
99
+ return `s3:https://${repo.endpoint}/${repo.bucket}`;
102
100
  case "local":
103
- env.RESTIC_REPOSITORY = repo.path;
104
- break;
101
+ return repo.path;
105
102
  case "sftp":
106
- env.RESTIC_REPOSITORY = `sftp:${repo.host}:${repo.path}`;
107
- break;
103
+ return `sftp:${repo.host}:${repo.path}`;
108
104
  }
105
+ }
106
+ function buildResticEnv(repo) {
107
+ const env = {
108
+ RESTIC_REPOSITORY: buildRepositoryLocation(repo)
109
+ };
109
110
  if (repo.credentials.password) env.RESTIC_PASSWORD = repo.credentials.password;
110
111
  if (repo.credentials.access_key_id) env.AWS_ACCESS_KEY_ID = repo.credentials.access_key_id;
111
112
  if (repo.credentials.secret_access_key) env.AWS_SECRET_ACCESS_KEY = repo.credentials.secret_access_key;
@@ -194,7 +195,7 @@ function buildRestoreArgs(name, project, options) {
194
195
  ];
195
196
  if (options.host) args.push(`--host=${options.host}`);
196
197
  args.push(`--target=${options.target}`);
197
- for (const path9 of options.includePaths || project.paths) args.push(`--include=${normalizeResticPath(path9, options.platform)}`);
198
+ for (const path10 of options.includePaths || project.paths) args.push(`--include=${normalizeResticPath(path10, options.platform)}`);
198
199
  if (options.dryRun) args.push("--dry-run");
199
200
  if (options.delete) args.push("--delete");
200
201
  if (options.verbose) args.push("--verbose=2");
@@ -205,6 +206,9 @@ function buildS3Options(repo) {
205
206
  if (repo.options?.bucket_lookup) {
206
207
  options.push("-o", `s3.bucket-lookup=${repo.options.bucket_lookup}`);
207
208
  }
209
+ if (repo.region) {
210
+ options.push("-o", `s3.region=${repo.region}`);
211
+ }
208
212
  return options;
209
213
  }
210
214
  async function execRestic(args, env, s3Options = []) {
@@ -230,6 +234,23 @@ async function runRestic(args, env, s3Options, capture) {
230
234
  }
231
235
  }
232
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
+
233
254
  // src/commands/backup.ts
234
255
  import fs4 from "fs";
235
256
  import os from "os";
@@ -318,7 +339,11 @@ async function backup(options) {
318
339
  if (failed.length > 0) console.log("Failed:", failed.join(", "));
319
340
  if (!dryRun) {
320
341
  for (const [pushUrl, result] of groupUptimeKumaResults(projectResults)) {
321
- 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);
322
347
  }
323
348
  }
324
349
  return { succeeded, failed, duration };
@@ -422,21 +447,6 @@ async function runProjectHooks(hooks, context) {
422
447
  }
423
448
  }
424
449
  }
425
- async function notifyUptimeKuma(result, pushUrl) {
426
- const status = result.failed.length > 0 ? "down" : "up";
427
- const msg = `Succeeded: ${result.succeeded.length}, Failed: ${result.failed.length}`;
428
- const url = `${pushUrl}?status=${status}&msg=${encodeURIComponent(msg)}&ping=${result.duration}`;
429
- try {
430
- const response = await fetch(url);
431
- if (response.ok) {
432
- console.log(`Uptime Kuma notified: status=${status}, ping=${result.duration}ms`);
433
- } else {
434
- console.error("Uptime Kuma notification failed");
435
- }
436
- } catch (error) {
437
- console.error("Uptime Kuma notification failed:", error);
438
- }
439
- }
440
450
 
441
451
  // src/commands/restore.ts
442
452
  import path5 from "path";
@@ -580,6 +590,13 @@ function buildResticOptions(s3Options, retryLock) {
580
590
  if (retryLock) options.push(`--retry-lock=${retryLock}`);
581
591
  return options;
582
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
+ }
583
600
  async function runStrictGc(retention, dryRun, env, resticOptions) {
584
601
  const snapshotsJson = await execResticCapture(["snapshots", "--json", "--no-lock"], env, resticOptions);
585
602
  const snapshots = JSON.parse(snapshotsJson);
@@ -596,12 +613,12 @@ async function runStrictGc(retention, dryRun, env, resticOptions) {
596
613
  console.log("No snapshots matched cleanup; prune was skipped.");
597
614
  return;
598
615
  }
599
- await execRestic(["forget", "--prune", ...plan.remove.map((snapshot) => snapshot.id)], env, resticOptions);
616
+ await executeGc(["forget", "--prune", ...plan.remove.map((snapshot) => snapshot.id)], false, env, resticOptions);
600
617
  }
601
618
  async function gc(options) {
602
619
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
603
620
  const config = loadConfig(wayDir, options.remote);
604
- const retention = config.rules.defaults?.retention || {};
621
+ const retention = options.retention ?? config.rules.defaults?.retention ?? {};
605
622
  const dryRun = options.dryRun || false;
606
623
  console.log("=== Cleaning snapshots ===");
607
624
  const env = buildResticEnv(config.repository);
@@ -613,11 +630,11 @@ async function gc(options) {
613
630
  await runStrictGc(retention, dryRun, env, resticOptions);
614
631
  return;
615
632
  }
616
- const keepDaily = retention.keep_daily || 7;
617
- const keepWeekly = retention.keep_weekly || 4;
618
- const keepMonthly = retention.keep_monthly || 6;
633
+ const keepDaily = retention.keep_daily ?? 7;
634
+ const keepWeekly = retention.keep_weekly ?? 4;
635
+ const keepMonthly = retention.keep_monthly ?? 6;
619
636
  const keepYearly = retention.keep_yearly;
620
- console.log(`Policy: daily=${keepDaily}, weekly=${keepWeekly}, monthly=${keepMonthly}${keepYearly ? `, yearly=${keepYearly}` : ""}`);
637
+ console.log(`Policy: daily=${keepDaily}, weekly=${keepWeekly}, monthly=${keepMonthly}${keepYearly !== void 0 ? `, yearly=${keepYearly}` : ""}`);
621
638
  const args = [
622
639
  "forget",
623
640
  "--prune",
@@ -625,9 +642,9 @@ async function gc(options) {
625
642
  `--keep-weekly=${keepWeekly}`,
626
643
  `--keep-monthly=${keepMonthly}`
627
644
  ];
628
- if (keepYearly) args.push(`--keep-yearly=${keepYearly}`);
645
+ if (keepYearly !== void 0) args.push(`--keep-yearly=${keepYearly}`);
629
646
  if (dryRun) args.push("--dry-run");
630
- await execRestic(args, env, resticOptions);
647
+ await executeGc(args, dryRun, env, resticOptions);
631
648
  }
632
649
 
633
650
  // src/commands/systemd.ts
@@ -823,6 +840,113 @@ async function windowsService(options, dependencies = {}) {
823
840
 
824
841
  // src/commands/daemon.ts
825
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
826
950
  var isRunning = false;
827
951
  var taskQueue = [];
828
952
  var CRON_OPTIONS = {
@@ -840,6 +964,24 @@ function assertValidSchedule(schedule, label) {
840
964
  throw new Error(`${label} must be a cron expression or false. Use false to disable scheduling.`);
841
965
  }
842
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
+ }
843
985
  function addScheduledBackup(scheduledBackups, schedule, projectName) {
844
986
  const projects = scheduledBackups.get(schedule) || [];
845
987
  projects.push(projectName);
@@ -882,13 +1024,37 @@ async function daemon(options) {
882
1024
  }, CRON_OPTIONS);
883
1025
  console.log(`Scheduled backup for ${projects.join(", ")}: ${schedule}`);
884
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
+ }
885
1050
  const pruneSchedule = config.rules.maintenance?.prune?.schedule;
886
1051
  assertValidSchedule(pruneSchedule, "maintenance.prune.schedule");
887
1052
  if (isEnabledSchedule(pruneSchedule)) {
1053
+ const prunePushUrl = resolvePrunePushUrl(config.rules);
888
1054
  cron.schedule(pruneSchedule, () => {
889
1055
  executeTask(async () => {
890
1056
  console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running prune`);
891
- await gc({ remote: options.remote, dryRun: false });
1057
+ await runPruneWithNotification(options.remote, prunePushUrl);
892
1058
  });
893
1059
  }, CRON_OPTIONS);
894
1060
  console.log(`Scheduled prune: ${pruneSchedule}`);
@@ -915,7 +1081,7 @@ async function daemon(options) {
915
1081
 
916
1082
  // src/cli.ts
917
1083
  var program = new Command();
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", `
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", `
919
1085
  \u793A\u4F8B:
920
1086
  $ way backup \u6267\u884C\u6240\u6709\u9879\u76EE\u5907\u4EFD
921
1087
  $ way backup data \u53EA\u5907\u4EFD data \u9879\u76EE
@@ -924,6 +1090,7 @@ program.name("way").version("0.6.11").description("\u7B56\u7565\u5907\u4EFD\u5DE
924
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
925
1091
  $ way init \u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6
926
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
927
1094
  $ way systemd install \u5B89\u88C5\u5B9A\u65F6\u4EFB\u52A1
928
1095
  $ way restic snapshots \u67E5\u770B\u5FEB\u7167\u5217\u8868
929
1096
  $ way restic restore abc123 --target /tmp/restore
@@ -952,17 +1119,17 @@ var commonHelpText = `
952
1119
  program.command("init").description("\u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6").addHelpText("after", commonHelpText).action(() => {
953
1120
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
954
1121
  const files = ["repositories.yaml", "rules.yaml"];
955
- fs7.mkdirSync(wayDir, { recursive: true });
1122
+ fs8.mkdirSync(wayDir, { recursive: true });
956
1123
  for (const file of files) {
957
- const target = path8.join(wayDir, file);
958
- if (fs7.existsSync(target)) {
1124
+ const target = path9.join(wayDir, file);
1125
+ if (fs8.existsSync(target)) {
959
1126
  throw new Error(`${target} already exists, aborting to avoid overwriting existing config.`);
960
1127
  }
961
1128
  }
962
1129
  for (const file of files) {
963
1130
  const source = resolveExampleConfigPath(file);
964
- const target = path8.join(wayDir, file);
965
- fs7.copyFileSync(source, target);
1131
+ const target = path9.join(wayDir, file);
1132
+ fs8.copyFileSync(source, target);
966
1133
  console.log(`Created ${target}`);
967
1134
  }
968
1135
  });
@@ -995,6 +1162,10 @@ program.command("gc").description("\u6E05\u7406\u65E7\u5FEB\u7167").option("--dr
995
1162
  const remote = resolveRemote(this);
996
1163
  await gc({ remote, dryRun: cmdOptions.dryRun });
997
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
+ });
998
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) => {
999
1170
  const remote = resolveRemote(command);
1000
1171
  await systemd({ remote, action });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shellus/way",
3
- "version": "0.6.11",
3
+ "version": "0.6.12",
4
4
  "description": "将备份作为持续运营的项目,而非一次性任务。基于 restic 的策略封装。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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:
@@ -98,10 +98,26 @@ global_excludes:
98
98
  - "*.tmp"
99
99
  - "*.swp"
100
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
+
101
115
  # 维护任务(可选)
102
116
  maintenance:
103
117
  prune:
104
118
  schedule: "0 4 * * 0" # 每周日凌晨 4 点清理
105
119
  # retry_lock: "30m" # 备份仍持有仓库锁时等待,避免立即失败
120
+ # uptime_kuma: # 定时 prune 成功推送 UP、失败推送 DOWN;未配置时回退到全局 uptime_kuma
121
+ # push_url: "https://uptime.example.com/api/push/prune-placeholder"
106
122
  check:
107
123
  schedule: false # 不执行