@shellus/way 0.6.10 → 0.6.11

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