@shellus/way 0.6.9 → 0.6.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
@@ -242,6 +279,8 @@ projects:
242
279
 
243
280
  `before_backup` 失败会跳过该项目的 restic 备份并标记项目失败;`after_backup` 只在 restic 成功后执行,失败同样会标记项目失败。`--dry-run` 模式只打印钩子命令,不实际执行。钩子命令按 shell 命令执行,并会收到 `WAY_PROJECT`、`WAY_REMOTE`、`WAY_DIR`、`WAY_DRY_RUN` 环境变量。
244
281
 
282
+ Linux 上的 hook 达到 `timeout` 后,Way 会终止对应 shell 的完整进程组;进程未在宽限期内退出时会继续强制终止,避免数据库导出、压缩或同步子进程脱离 Way 后持续运行。hook 仍应保持幂等,并自行清理失败时生成的临时文件。
283
+
245
284
  **项目级 Uptime Kuma 通知示例**:
246
285
 
247
286
  ```yaml
@@ -323,6 +362,8 @@ way backup data_deps
323
362
 
324
363
  `schedule` 支持 node-cron 字符串或 `false`。`false` 表示不创建自动调度任务,只能通过 `way backup <project>` 或 `way backup` 手动触发。项目未设置 `schedule` 时继承 `defaults.schedule`;如果全局和项目都未设置,则不自动调度。
325
364
 
365
+ daemon 的调度心跳最多容忍 5 秒延迟;事件循环短暂阻塞后仍会补执行本轮任务,超过窗口则由 node-cron 报告为 missed execution。
366
+
326
367
  | 格式 | 说明 | 示例 |
327
368
  |------|------|------|
328
369
  | `"0 */2 * * *"` | 间隔表达式 | 每 2 小时 |
@@ -359,6 +400,8 @@ repositories:
359
400
 
360
401
  建议设置文件权限:`chmod 600 ~/.way/repositories.yaml`
361
402
 
403
+ `repositories.yaml` 和 `rules.yaml` 支持 YAML merge anchor(`<<`);Way 使用 YAML 1.2 Core Schema 并显式启用 merge tag,不会同时启用 YAML 1.1 的日期自动转换等额外类型。
404
+
362
405
  ## 配置备份
363
406
 
364
407
  `~/.way/` 目录包含所有配置和凭证,建议整体备份到安全位置:
@@ -460,4 +503,4 @@ way --remote=oss restic restore <snapshot-id> --target /tmp/restore
460
503
  - 变更规范和测试要求
461
504
  - 发布流程
462
505
 
463
- 内部开发参考:[CLAUDE.md](CLAUDE.md)
506
+ 项目维护规则:[AGENTS.md](AGENTS.md)
package/dist/cli.js CHANGED
@@ -2,22 +2,24 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
- import 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);
@@ -324,6 +342,7 @@ function groupUptimeKumaResults(projectResults) {
324
342
  }
325
343
  return groupedResults;
326
344
  }
345
+ var HOOK_FORCE_KILL_DELAY_MS = 1e3;
327
346
  function normalizeHook(hook) {
328
347
  if (typeof hook === "string") return { run: hook };
329
348
  return hook;
@@ -346,6 +365,26 @@ function parseTimeout(timeout) {
346
365
  return value * 60 * 60 * 1e3;
347
366
  }
348
367
  }
368
+ function isMissingProcessError(error) {
369
+ return error instanceof Error && "code" in error && error.code === "ESRCH";
370
+ }
371
+ function signalProcessGroup(pid, signal) {
372
+ try {
373
+ process.kill(-pid, signal);
374
+ return true;
375
+ } catch (error) {
376
+ if (isMissingProcessError(error)) return false;
377
+ throw error;
378
+ }
379
+ }
380
+ function hookTimedOut(error) {
381
+ return typeof error === "object" && error !== null && "timedOut" in error && error.timedOut === true;
382
+ }
383
+ async function terminateProcessGroup(pid) {
384
+ if (!signalProcessGroup(pid, "SIGTERM")) return;
385
+ await new Promise((resolve) => setTimeout(resolve, HOOK_FORCE_KILL_DELAY_MS));
386
+ signalProcessGroup(pid, "SIGKILL");
387
+ }
349
388
  async function runProjectHooks(hooks, context) {
350
389
  if (!hooks?.length) return;
351
390
  for (const hook of hooks) {
@@ -356,10 +395,11 @@ async function runProjectHooks(hooks, context) {
356
395
  continue;
357
396
  }
358
397
  console.log(`Running ${context.label} hook for ${context.projectName}: ${normalized.run}`);
359
- await execaCommand(normalized.run, {
398
+ const subprocess = execa2(normalized.run, {
360
399
  shell: true,
361
400
  stdio: "inherit",
362
401
  timeout: parseTimeout(normalized.timeout),
402
+ detached: process.platform !== "win32",
363
403
  env: {
364
404
  WAY_PROJECT: context.projectName,
365
405
  WAY_REMOTE: context.remote,
@@ -367,6 +407,19 @@ async function runProjectHooks(hooks, context) {
367
407
  WAY_DRY_RUN: context.dryRun ? "1" : "0"
368
408
  }
369
409
  });
410
+ const processGroupPid = process.platform === "win32" ? void 0 : subprocess.pid;
411
+ const cleanupOnExit = processGroupPid === void 0 ? void 0 : () => signalProcessGroup(processGroupPid, "SIGTERM");
412
+ if (cleanupOnExit) process.once("exit", cleanupOnExit);
413
+ try {
414
+ await subprocess;
415
+ } catch (error) {
416
+ if (processGroupPid !== void 0 && hookTimedOut(error)) {
417
+ await terminateProcessGroup(processGroupPid);
418
+ }
419
+ throw error;
420
+ } finally {
421
+ if (cleanupOnExit) process.removeListener("exit", cleanupOnExit);
422
+ }
370
423
  }
371
424
  }
372
425
  async function notifyUptimeKuma(result, pushUrl) {
@@ -386,8 +439,46 @@ async function notifyUptimeKuma(result, pushUrl) {
386
439
  }
387
440
 
388
441
  // src/commands/restore.ts
442
+ import path5 from "path";
443
+ function isWithin(parent, child, pathApi) {
444
+ const relative = pathApi.relative(parent, child);
445
+ return relative === "" || !relative.startsWith("..") && !pathApi.isAbsolute(relative);
446
+ }
447
+ function findCommonParent(paths, pathApi) {
448
+ let candidate = paths[0];
449
+ while (!paths.every((item) => isWithin(candidate, item, pathApi))) {
450
+ const parent = pathApi.dirname(candidate);
451
+ if (parent === candidate) return candidate;
452
+ candidate = parent;
453
+ }
454
+ return candidate;
455
+ }
456
+ function buildWindowsRestorePlans(project, target, snapshot = "latest") {
457
+ const pathApi = path5.win32;
458
+ const groups = /* @__PURE__ */ new Map();
459
+ for (const sourcePath of project.paths) {
460
+ const root = pathApi.parse(sourcePath).root.toLowerCase();
461
+ const paths = groups.get(root) || [];
462
+ paths.push(sourcePath);
463
+ groups.set(root, paths);
464
+ }
465
+ return Array.from(groups.values()).map((paths) => {
466
+ const parent = findCommonParent(paths.map((item) => pathApi.dirname(item)), pathApi);
467
+ const drive = parent[0].toUpperCase();
468
+ const rest = parent.slice(2).replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
469
+ const snapshotPath = rest ? `/${drive}/${rest}` : `/${drive}`;
470
+ const includePaths = paths.map((item) => `/${pathApi.relative(parent, item).replace(/\\/g, "/")}`);
471
+ const targetPath = pathApi.join(target, ...snapshotPath.split("/").filter(Boolean));
472
+ return {
473
+ snapshot: `${snapshot}:${snapshotPath}`,
474
+ target: targetPath,
475
+ includePaths: Array.from(new Set(includePaths))
476
+ };
477
+ });
478
+ }
389
479
  async function restore(options) {
390
480
  if (!options.target) throw new Error("--target is required");
481
+ const target = options.target;
391
482
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
392
483
  const config = loadConfig(wayDir, options.remote);
393
484
  const env = buildResticEnv(config.repository);
@@ -405,15 +496,19 @@ async function restore(options) {
405
496
  }
406
497
  console.log(`=== Restoring: ${projectName} ===`);
407
498
  try {
408
- const args = buildRestoreArgs(projectName, project, {
409
- target: options.target,
410
- snapshot: options.snapshot,
411
- host: options.host,
412
- dryRun: options.dryRun,
413
- delete: options.delete,
414
- verbose: options.verbose
415
- });
416
- 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
+ }
417
512
  succeeded.push(projectName);
418
513
  } catch (error) {
419
514
  console.error(`Failed to restore ${projectName}:`, error);
@@ -428,18 +523,101 @@ async function restore(options) {
428
523
  }
429
524
 
430
525
  // src/commands/gc.ts
526
+ var DAY_MS = 24 * 60 * 60 * 1e3;
527
+ function hasLegacyPolicy(retention) {
528
+ return [
529
+ retention.keep_daily,
530
+ retention.keep_weekly,
531
+ retention.keep_monthly,
532
+ retention.keep_yearly
533
+ ].some((value) => value !== void 0);
534
+ }
535
+ function isStrictPolicy(retention) {
536
+ return retention.keep_hosts !== void 0 || retention.max_age_days !== void 0;
537
+ }
538
+ function validateStrictPolicy(retention) {
539
+ if (!retention.keep_hosts?.length) {
540
+ throw new Error("Strict retention requires at least one defaults.retention.keep_hosts entry");
541
+ }
542
+ if (!Number.isFinite(retention.max_age_days) || retention.max_age_days <= 0) {
543
+ throw new Error("Strict retention requires defaults.retention.max_age_days to be a positive number");
544
+ }
545
+ if (hasLegacyPolicy(retention)) {
546
+ throw new Error("Strict keep_hosts/max_age_days retention cannot be combined with count-based keep_* retention");
547
+ }
548
+ const normalizedHosts = retention.keep_hosts.map((host) => host.trim());
549
+ if (normalizedHosts.some((host) => host.length === 0)) {
550
+ throw new Error("defaults.retention.keep_hosts cannot contain empty host names");
551
+ }
552
+ return { hosts: new Set(normalizedHosts), maxAgeDays: retention.max_age_days };
553
+ }
554
+ function buildSnapshotRetentionPlan(snapshots, retention, now = /* @__PURE__ */ new Date()) {
555
+ const { hosts, maxAgeDays } = validateStrictPolicy(retention);
556
+ const cutoff = new Date(now.getTime() - maxAgeDays * DAY_MS);
557
+ const keep = [];
558
+ const remove = [];
559
+ for (const snapshot of snapshots) {
560
+ const snapshotTime = new Date(snapshot.time);
561
+ if (Number.isNaN(snapshotTime.getTime())) {
562
+ throw new Error(`Snapshot ${snapshot.id} has an invalid timestamp: ${snapshot.time}`);
563
+ }
564
+ if (hosts.has(snapshot.hostname) && snapshotTime >= cutoff) {
565
+ keep.push(snapshot);
566
+ } else {
567
+ remove.push(snapshot);
568
+ }
569
+ }
570
+ return { cutoff, keep, remove };
571
+ }
572
+ function printSnapshots(label, snapshots) {
573
+ console.log(`${label} (${snapshots.length}):`);
574
+ for (const snapshot of snapshots) {
575
+ console.log(` ${snapshot.id.slice(0, 8)} ${snapshot.time} ${snapshot.hostname} ${(snapshot.tags || []).join(",") || "-"}`);
576
+ }
577
+ }
578
+ function buildResticOptions(s3Options, retryLock) {
579
+ const options = [...s3Options];
580
+ if (retryLock) options.push(`--retry-lock=${retryLock}`);
581
+ return options;
582
+ }
583
+ async function runStrictGc(retention, dryRun, env, resticOptions) {
584
+ const snapshotsJson = await execResticCapture(["snapshots", "--json", "--no-lock"], env, resticOptions);
585
+ const snapshots = JSON.parse(snapshotsJson);
586
+ if (!Array.isArray(snapshots)) throw new Error("restic snapshots --json did not return an array");
587
+ const plan = buildSnapshotRetentionPlan(snapshots, retention);
588
+ console.log(`Policy: keep hosts=${retention.keep_hosts.join(",")}, max age=${retention.max_age_days} days, cutoff=${plan.cutoff.toISOString()}`);
589
+ printSnapshots("Keep snapshots", plan.keep);
590
+ printSnapshots("Remove snapshots", plan.remove);
591
+ if (dryRun) {
592
+ console.log("Dry-run complete; no snapshots or repository data were modified.");
593
+ return;
594
+ }
595
+ if (plan.remove.length === 0) {
596
+ console.log("No snapshots matched cleanup; prune was skipped.");
597
+ return;
598
+ }
599
+ await execRestic(["forget", "--prune", ...plan.remove.map((snapshot) => snapshot.id)], env, resticOptions);
600
+ }
431
601
  async function gc(options) {
432
602
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
433
603
  const config = loadConfig(wayDir, options.remote);
434
604
  const retention = config.rules.defaults?.retention || {};
605
+ const dryRun = options.dryRun || false;
606
+ console.log("=== Cleaning snapshots ===");
607
+ const env = buildResticEnv(config.repository);
608
+ const resticOptions = buildResticOptions(
609
+ buildS3Options(config.repository),
610
+ config.rules.maintenance?.prune?.retry_lock
611
+ );
612
+ if (isStrictPolicy(retention)) {
613
+ await runStrictGc(retention, dryRun, env, resticOptions);
614
+ return;
615
+ }
435
616
  const keepDaily = retention.keep_daily || 7;
436
617
  const keepWeekly = retention.keep_weekly || 4;
437
618
  const keepMonthly = retention.keep_monthly || 6;
438
619
  const keepYearly = retention.keep_yearly;
439
- console.log("=== Cleaning snapshots ===");
440
620
  console.log(`Policy: daily=${keepDaily}, weekly=${keepWeekly}, monthly=${keepMonthly}${keepYearly ? `, yearly=${keepYearly}` : ""}`);
441
- const env = buildResticEnv(config.repository);
442
- const s3Options = buildS3Options(config.repository);
443
621
  const args = [
444
622
  "forget",
445
623
  "--prune",
@@ -448,22 +626,22 @@ async function gc(options) {
448
626
  `--keep-monthly=${keepMonthly}`
449
627
  ];
450
628
  if (keepYearly) args.push(`--keep-yearly=${keepYearly}`);
451
- if (options.dryRun) args.push("--dry-run");
452
- await execRestic(args, env, s3Options);
629
+ if (dryRun) args.push("--dry-run");
630
+ await execRestic(args, env, resticOptions);
453
631
  }
454
632
 
455
633
  // src/commands/systemd.ts
456
634
  import { execSync } from "child_process";
457
635
  import fs5 from "fs";
458
- import path5 from "path";
636
+ import path6 from "path";
459
637
  function resolveWayCommandPath(options = {}) {
460
638
  const env = options.env ?? process.env;
461
639
  const argv = options.argv ?? process.argv;
462
640
  const execPath = options.execPath ?? process.execPath;
463
641
  const whichWay = options.whichWay ?? (() => execSync("which way", { encoding: "utf-8" }).trim());
464
642
  if (env.WAY_BIN) return env.WAY_BIN;
465
- if (path5.basename(execPath) === "way") return execPath;
466
- 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];
467
645
  return whichWay();
468
646
  }
469
647
  async function systemd(options) {
@@ -493,7 +671,7 @@ WantedBy=multi-user.target
493
671
  return;
494
672
  }
495
673
  const systemdDir = "/etc/systemd/system";
496
- const servicePath = path5.join(systemdDir, "way-backup.service");
674
+ const servicePath = path6.join(systemdDir, "way-backup.service");
497
675
  if (options.action === "install") {
498
676
  fs5.writeFileSync(servicePath, serviceContent);
499
677
  execSync("systemctl daemon-reload");
@@ -520,10 +698,136 @@ WantedBy=multi-user.target
520
698
  }
521
699
  }
522
700
 
701
+ // src/commands/windows-service.ts
702
+ import { execFileSync } from "child_process";
703
+ import fs6 from "fs";
704
+ import os2 from "os";
705
+ import path7 from "path";
706
+ var WINDOWS_TASK_NAME = "Way Backup Daemon";
707
+ var WINDOWS_RUNNER_NAME = "way-daemon.cmd";
708
+ function assertWindows(platform) {
709
+ if (platform !== "win32") throw new Error("windows-service is only available on Windows");
710
+ }
711
+ function assertSafeCmdValue(value, name) {
712
+ if (/[\r\n\0]/.test(value)) throw new Error(`${name} cannot contain line breaks or NUL bytes`);
713
+ }
714
+ function xmlEscape(value) {
715
+ return value.replace(/&/g, "&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
+
523
824
  // src/commands/daemon.ts
524
825
  import cron from "node-cron";
525
826
  var isRunning = false;
526
827
  var taskQueue = [];
828
+ var CRON_OPTIONS = {
829
+ missedExecutionTolerance: 5e3
830
+ };
527
831
  function resolveProjectSchedule(project, defaults = {}) {
528
832
  if (project.schedule !== void 0) return project.schedule;
529
833
  return defaults?.schedule ?? false;
@@ -575,7 +879,7 @@ async function daemon(options) {
575
879
  console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running backup: ${projects.join(", ")}`);
576
880
  await backup({ remote: options.remote, projects });
577
881
  });
578
- });
882
+ }, CRON_OPTIONS);
579
883
  console.log(`Scheduled backup for ${projects.join(", ")}: ${schedule}`);
580
884
  }
581
885
  const pruneSchedule = config.rules.maintenance?.prune?.schedule;
@@ -586,7 +890,7 @@ async function daemon(options) {
586
890
  console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running prune`);
587
891
  await gc({ remote: options.remote, dryRun: false });
588
892
  });
589
- });
893
+ }, CRON_OPTIONS);
590
894
  console.log(`Scheduled prune: ${pruneSchedule}`);
591
895
  }
592
896
  const checkSchedule = config.rules.maintenance?.check?.schedule;
@@ -596,7 +900,7 @@ async function daemon(options) {
596
900
  executeTask(async () => {
597
901
  console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running check`);
598
902
  });
599
- });
903
+ }, CRON_OPTIONS);
600
904
  console.log(`Scheduled check: ${checkSchedule}`);
601
905
  }
602
906
  process.on("SIGTERM", () => {
@@ -611,7 +915,7 @@ async function daemon(options) {
611
915
 
612
916
  // src/cli.ts
613
917
  var program = new Command();
614
- program.name("way").version("0.6.9").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", `
615
919
  \u793A\u4F8B:
616
920
  $ way backup \u6267\u884C\u6240\u6709\u9879\u76EE\u5907\u4EFD
617
921
  $ way backup data \u53EA\u5907\u4EFD data \u9879\u76EE
@@ -634,6 +938,9 @@ program.name("way").version("0.6.9").description("\u7B56\u7565\u5907\u4EFD\u5DE5
634
938
  function collectBackupArgs(command) {
635
939
  return command.args.filter((a) => a.startsWith("-") && !["--dry-run"].includes(a));
636
940
  }
941
+ function resolveRemote(command) {
942
+ return command.optsWithGlobals().remote;
943
+ }
637
944
  var commonHelpText = `
638
945
  \u5168\u5C40\u7528\u6CD5:
639
946
  way --remote=oss <command> ... \u6307\u5B9A\u4ED3\u5E93\uFF08\u5168\u5C40\u9009\u9879\u9700\u653E\u5728\u5B50\u547D\u4EE4\u524D\uFF09
@@ -645,22 +952,22 @@ var commonHelpText = `
645
952
  program.command("init").description("\u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6").addHelpText("after", commonHelpText).action(() => {
646
953
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
647
954
  const files = ["repositories.yaml", "rules.yaml"];
648
- fs6.mkdirSync(wayDir, { recursive: true });
955
+ fs7.mkdirSync(wayDir, { recursive: true });
649
956
  for (const file of files) {
650
- const target = path6.join(wayDir, file);
651
- if (fs6.existsSync(target)) {
957
+ const target = path8.join(wayDir, file);
958
+ if (fs7.existsSync(target)) {
652
959
  throw new Error(`${target} already exists, aborting to avoid overwriting existing config.`);
653
960
  }
654
961
  }
655
962
  for (const file of files) {
656
963
  const source = resolveExampleConfigPath(file);
657
- const target = path6.join(wayDir, file);
658
- fs6.copyFileSync(source, target);
964
+ const target = path8.join(wayDir, file);
965
+ fs7.copyFileSync(source, target);
659
966
  console.log(`Created ${target}`);
660
967
  }
661
968
  });
662
969
  program.command("backup [projects...]").description("\u6309 rules.yaml \u6267\u884C\u5907\u4EFD").option("--dry-run", "\u6A21\u62DF\u5907\u4EFD\uFF08\u4E0D\u5B9E\u9645\u5199\u5165\uFF09").addHelpText("after", commonHelpText).allowUnknownOption().allowExcessArguments().action(async function(projects) {
663
- const remote = this.parent.opts().remote;
970
+ const remote = resolveRemote(this);
664
971
  const dryRun = this.opts().dryRun;
665
972
  const extraArgs = collectBackupArgs(this);
666
973
  const result = await backup({
@@ -672,7 +979,7 @@ program.command("backup [projects...]").description("\u6309 rules.yaml \u6267\u8
672
979
  if (result.failed.length > 0) process.exitCode = 1;
673
980
  });
674
981
  program.command("restore [projects...]").description("\u6309 rules.yaml \u6062\u590D\u9879\u76EE").requiredOption("--target <dir>", "\u6062\u590D\u76EE\u6807\u76EE\u5F55").option("--snapshot <snapshot>", "\u5FEB\u7167 ID \u6216 latest", "latest").option("--host <host>", "\u53EA\u6062\u590D\u6307\u5B9A host \u7684\u5FEB\u7167").option("--dry-run", "\u6A21\u62DF\u6062\u590D\uFF08\u4E0D\u5B9E\u9645\u5199\u5165\uFF09").option("--delete", "\u5220\u9664\u76EE\u6807\u4E2D\u5FEB\u7167\u4E0D\u5B58\u5728\u7684\u6587\u4EF6").option("-v, --verbose", "\u663E\u793A\u8BE6\u7EC6\u6062\u590D\u8BA1\u5212\uFF08\u4F20\u9012 --verbose=2 \u7ED9 restic\uFF09").addHelpText("after", commonHelpText).action(async function(projects, cmdOptions) {
675
- const remote = this.parent.opts().remote;
982
+ const remote = resolveRemote(this);
676
983
  await restore({
677
984
  remote,
678
985
  projects,
@@ -685,15 +992,19 @@ program.command("restore [projects...]").description("\u6309 rules.yaml \u6062\u
685
992
  });
686
993
  });
687
994
  program.command("gc").description("\u6E05\u7406\u65E7\u5FEB\u7167").option("--dry-run", "\u6A21\u62DF\u6E05\u7406\uFF08\u4E0D\u5B9E\u9645\u5220\u9664\uFF09").addHelpText("after", commonHelpText).action(async function(cmdOptions) {
688
- const remote = this.parent.opts().remote;
995
+ const remote = resolveRemote(this);
689
996
  await gc({ remote, dryRun: cmdOptions.dryRun });
690
997
  });
691
998
  program.command("systemd <action>").description("\u7BA1\u7406 systemd \u5B9A\u65F6\u4EFB\u52A1 (show|install|uninstall|status)").addHelpText("after", commonHelpText).action(async (action, options, command) => {
692
- const remote = command.parent.opts().remote;
999
+ const remote = resolveRemote(command);
693
1000
  await systemd({ remote, action });
694
1001
  });
1002
+ program.command("windows-service <action>").description("\u7BA1\u7406 Windows \u539F\u751F\u5F00\u673A\u5B88\u62A4\u4EFB\u52A1 (show|install|uninstall|status)").addHelpText("after", commonHelpText).action(async (action, options, command) => {
1003
+ const remote = resolveRemote(command);
1004
+ await windowsService({ remote, action });
1005
+ });
695
1006
  program.command("daemon").description("\u542F\u52A8\u5E38\u9A7B\u8FDB\u7A0B\uFF0C\u6309\u914D\u7F6E\u5B9A\u65F6\u6267\u884C\u5907\u4EFD").addHelpText("after", commonHelpText).action(async (options, command) => {
696
- const remote = command.parent.opts().remote;
1007
+ const remote = resolveRemote(command);
697
1008
  await daemon({ remote });
698
1009
  });
699
1010
  program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").addHelpText("after", commonHelpText).action(() => {
@@ -703,7 +1014,7 @@ program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").addHe
703
1014
  }
704
1015
  });
705
1016
  program.command("restic [args...]").description("\u663E\u5F0F\u900F\u4F20\u7ED9 restic").addHelpText("after", commonHelpText).allowUnknownOption().allowExcessArguments().action(async function(args) {
706
- const remote = this.parent.opts().remote;
1017
+ const remote = resolveRemote(this);
707
1018
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
708
1019
  const config = loadConfig(wayDir, remote);
709
1020
  const env = buildResticEnv(config.repository);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shellus/way",
3
- "version": "0.6.9",
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 # 不执行