@shellus/way 0.4.2 → 0.5.1

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
@@ -6,7 +6,8 @@
6
6
 
7
7
  ## 版本说明
8
8
 
9
- - **v0.4.0+**: TypeScript 版本,使用 systemd,凭证直接写在 YAML
9
+ - **v0.5.0+**: daemon 模式,支持项目级调度配置
10
+ - **v0.4.0**: TypeScript 版本,使用 systemd timer,凭证直接写在 YAML
10
11
  - **v0.3.x**: TypeScript 版本,使用 crontab,支持 .env
11
12
  - **v0.2.x**: Bash 版本
12
13
 
@@ -46,10 +47,11 @@
46
47
 
47
48
  假设本机被入侵,攻击者不应能通过本机凭证定位或删除备份数据。
48
49
 
49
- **v0.4.0 安全增强**:
50
- - 使用 systemd 替代 crontab(避免在 crontab 留下痕迹)
51
- - 移除 .env 文件支持(避免被 `find .env` 发现)
52
- - 凭证直接写在 `~/.way/repositories.yaml`(权限 600)
50
+ **v0.5.0 架构升级**:
51
+ - 使用 daemon 模式替代 systemd timer
52
+ - 支持项目级 schedule 配置(不同项目不同频率)
53
+ - 使用 node-cron 实现精确到分钟的调度
54
+ - systemd service 管理常驻进程(自动重启)
53
55
 
54
56
  ### 9. 多层冗余原则
55
57
 
@@ -119,15 +121,20 @@ way snapshots # 查看快照
119
121
 
120
122
  ```bash
121
123
  # 备份命令
122
- way run # 执行备份(读取 rules.yaml 的项目和排除规则)
124
+ way run # 执行所有项目备份
123
125
  way run data # 只备份 data 项目
126
+ way run data config # 备份多个项目
124
127
  way gc # 按 retention 策略清理旧快照
128
+ way gc --dry-run # 模拟清理(不实际删除)
125
129
 
126
- # systemd 定时任务
127
- way systemd install # 安装 systemd 定时任务
130
+ # daemon 模式(推荐)
131
+ way daemon # 启动常驻进程,按配置定时执行
132
+
133
+ # systemd 管理
134
+ way systemd install # 安装 systemd service(运行 daemon)
128
135
  way systemd show # 显示 systemd 配置
129
- way systemd status # 查看定时任务状态
130
- way systemd uninstall # 卸载定时任务
136
+ way systemd status # 查看服务状态
137
+ way systemd uninstall # 卸载服务
131
138
 
132
139
  # 透传 restic(way 只设置环境变量)
133
140
  way snapshots # → restic snapshots
@@ -143,16 +150,14 @@ way --remote=oss snapshots
143
150
 
144
151
  ```mermaid
145
152
  graph LR
146
- A[way run] --> B[读取配置]
147
- B --> C[遍历项目]
148
- C --> D[执行 restic backup]
149
- D --> E[推送 Uptime Kuma]
150
-
151
- F[systemd timer] --> G[定时触发]
152
- G --> A
153
-
154
- H[way gc] --> I[restic forget]
155
- I --> J[restic prune]
153
+ A[way daemon] --> B[读取配置]
154
+ B --> C[为每个项目创建 cron 任务]
155
+ C --> D[node-cron 定时触发]
156
+ D --> E[执行 restic backup]
157
+ E --> F[推送 Uptime Kuma]
158
+
159
+ G[systemd service] --> H[启动 daemon]
160
+ H --> I[进程崩溃自动重启]
156
161
  ```
157
162
 
158
163
  ---
@@ -174,13 +179,45 @@ WAY_DIR=/path/to/config way snapshots
174
179
 
175
180
  ### rules.yaml
176
181
 
177
- 备份规则配置,参考 [`rules.yaml`](rules.yaml):
182
+ 备份规则配置,参考 [`rules.yaml.example`](rules.yaml.example):
178
183
 
179
- - **projects**: 备份项目、路径、专属排除规则
180
- - **schedule**: 备份时间(systemd timer OnCalendar 格式)
181
- - **retention**: 快照保留策略
184
+ - **defaults**: 全局默认配置(schedule、retention)
185
+ - **projects**: 备份项目配置,可覆盖默认 schedule retention
186
+ - **maintenance**: 维护任务配置(prune、check)
182
187
  - **global_excludes**: 全局排除规则
183
188
 
189
+ **项目级调度示例**:
190
+
191
+ ```yaml
192
+ defaults:
193
+ schedule: "0 */2 * * *" # 默认每 2 小时
194
+
195
+ projects:
196
+ data:
197
+ paths: [/data]
198
+ # 继承默认 schedule
199
+
200
+ logs:
201
+ paths: [/var/log]
202
+ schedule: "0 3 * * *" # 覆盖为每天凌晨 3 点
203
+
204
+ archive:
205
+ paths: [/archive]
206
+ schedule: "0 2 * * 0" # 每周日凌晨 2 点
207
+ retention:
208
+ keep_weekly: 8
209
+ keep_monthly: 12
210
+ ```
211
+
212
+ **schedule 语法**(node-cron 格式):
213
+
214
+ | 格式 | 说明 | 示例 |
215
+ |------|------|------|
216
+ | `"0 */2 * * *"` | 间隔表达式 | 每 2 小时 |
217
+ | `"0 9,15,21 * * *"` | 多个时间点(逗号) | 每天 9:00、15:00、21:00 |
218
+ | `"0 9-17 * * 1-5"` | 时间范围 | 工作日 9:00-17:00 每小时 |
219
+ | `"*/30 * * * *"` | 分钟间隔 | 每 30 分钟 |
220
+
184
221
  #### 排除规则通配符语法
185
222
 
186
223
  restic 使用 Go 的 filepath.Match 语法:
package/dist/cli.js CHANGED
@@ -15,6 +15,9 @@ function loadConfig(wayDir, remoteName) {
15
15
  if (!repository) throw new Error(`Repository not found: ${repoName}`);
16
16
  const rulesFile = path.join(wayDir, "rules.yaml");
17
17
  const rules = yaml.load(fs.readFileSync(rulesFile, "utf8"));
18
+ if ("schedule" in rules && "backup" in (rules.schedule || {})) {
19
+ 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");
20
+ }
18
21
  return { repository, rules };
19
22
  }
20
23
 
@@ -72,7 +75,7 @@ async function run(options) {
72
75
  const env = buildResticEnv(config.repository);
73
76
  const s3Options = buildS3Options(config.repository);
74
77
  const globalExcludes = config.rules.global_excludes || [];
75
- const projects = options.project ? [options.project] : Object.keys(config.rules.projects);
78
+ const projects = options.projects && options.projects.length > 0 ? options.projects : Object.keys(config.rules.projects);
76
79
  const succeeded = [];
77
80
  const failed = [];
78
81
  const startTime = Date.now();
@@ -123,11 +126,13 @@ async function notifyUptimeKuma(result, pushUrl) {
123
126
  async function gc(options) {
124
127
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
125
128
  const config = loadConfig(wayDir, options.remote);
126
- const keepDaily = config.rules.retention?.keep_daily || 7;
127
- const keepWeekly = config.rules.retention?.keep_weekly || 4;
128
- const keepMonthly = config.rules.retention?.keep_monthly || 6;
129
+ const retention = config.rules.defaults?.retention || {};
130
+ const keepDaily = retention.keep_daily || 7;
131
+ const keepWeekly = retention.keep_weekly || 4;
132
+ const keepMonthly = retention.keep_monthly || 6;
133
+ const keepYearly = retention.keep_yearly;
129
134
  console.log("=== Cleaning snapshots ===");
130
- console.log(`Policy: daily=${keepDaily}, weekly=${keepWeekly}, monthly=${keepMonthly}`);
135
+ console.log(`Policy: daily=${keepDaily}, weekly=${keepWeekly}, monthly=${keepMonthly}${keepYearly ? `, yearly=${keepYearly}` : ""}`);
131
136
  const env = buildResticEnv(config.repository);
132
137
  const s3Options = buildS3Options(config.repository);
133
138
  const args = [
@@ -137,7 +142,8 @@ async function gc(options) {
137
142
  `--keep-weekly=${keepWeekly}`,
138
143
  `--keep-monthly=${keepMonthly}`
139
144
  ];
140
- if (options.extraArgs) args.push(...options.extraArgs);
145
+ if (keepYearly) args.push(`--keep-yearly=${keepYearly}`);
146
+ if (options.dryRun) args.push("--dry-run");
141
147
  await execRestic(args, env, s3Options);
142
148
  }
143
149
 
@@ -145,80 +151,124 @@ async function gc(options) {
145
151
  import { execSync } from "child_process";
146
152
  import fs2 from "fs";
147
153
  import path2 from "path";
148
- function cronToSystemd(cron) {
149
- const parts = cron.trim().split(/\s+/);
150
- if (parts.length !== 5) return "daily";
151
- const [minute, hour, day, month, weekday] = parts;
152
- if (day === "*" && month === "*" && weekday === "*") {
153
- return `*-*-* ${hour.padStart(2, "0")}:${minute.padStart(2, "0")}:00`;
154
- }
155
- return "daily";
156
- }
157
154
  async function systemd(options) {
158
155
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
159
156
  const config = loadConfig(wayDir, options.remote);
160
157
  const wayPath = execSync("which way", { encoding: "utf-8" }).trim();
158
+ const currentUser = execSync("whoami", { encoding: "utf-8" }).trim();
161
159
  const serviceContent = `[Unit]
162
- Description=Way Backup Service
160
+ Description=Way Backup Daemon
163
161
  After=network.target
164
162
 
165
163
  [Service]
166
- Type=oneshot
167
- ExecStart=${wayPath} run
164
+ Type=simple
165
+ User=${currentUser}
166
+ ExecStart=${wayPath} daemon
167
+ Restart=always
168
+ RestartSec=10
168
169
  Environment="WAY_DIR=${wayDir}"
169
- `;
170
- const timerContent = `[Unit]
171
- Description=Way Backup Timer
172
-
173
- [Timer]
174
- OnCalendar=${cronToSystemd(config.rules.schedule?.backup?.[0] || "daily")}
175
- Persistent=true
170
+ Environment="HOME=${process.env.HOME}"
176
171
 
177
172
  [Install]
178
- WantedBy=timers.target
173
+ WantedBy=multi-user.target
179
174
  `;
180
175
  if (options.action === "show") {
181
176
  console.log("=== way-backup.service ===");
182
177
  console.log(serviceContent);
183
- console.log("\n=== way-backup.timer ===");
184
- console.log(timerContent);
185
178
  return;
186
179
  }
187
- const systemdDir = `${process.env.HOME}/.config/systemd/user`;
180
+ const systemdDir = "/etc/systemd/system";
188
181
  const servicePath = path2.join(systemdDir, "way-backup.service");
189
- const timerPath = path2.join(systemdDir, "way-backup.timer");
190
182
  if (options.action === "install") {
191
- fs2.mkdirSync(systemdDir, { recursive: true });
192
183
  fs2.writeFileSync(servicePath, serviceContent);
193
- fs2.writeFileSync(timerPath, timerContent);
194
- execSync("systemctl --user daemon-reload");
195
- execSync("systemctl --user enable way-backup.timer");
196
- execSync("systemctl --user start way-backup.timer");
197
- console.log("Systemd timer installed and started");
198
- execSync("systemctl --user --no-pager status way-backup.timer", { stdio: "inherit" });
184
+ execSync("systemctl daemon-reload");
185
+ execSync("systemctl enable way-backup.service");
186
+ execSync("systemctl start way-backup.service");
187
+ console.log("Systemd service installed and started");
188
+ execSync("systemctl --no-pager status way-backup.service", { stdio: "inherit" });
199
189
  }
200
190
  if (options.action === "uninstall") {
201
191
  try {
202
- execSync("systemctl --user stop way-backup.timer", { stdio: "ignore" });
192
+ execSync("systemctl stop way-backup.service", { stdio: "ignore" });
203
193
  } catch {
204
194
  }
205
195
  try {
206
- execSync("systemctl --user disable way-backup.timer", { stdio: "ignore" });
196
+ execSync("systemctl disable way-backup.service", { stdio: "ignore" });
207
197
  } catch {
208
198
  }
209
199
  if (fs2.existsSync(servicePath)) fs2.unlinkSync(servicePath);
210
- if (fs2.existsSync(timerPath)) fs2.unlinkSync(timerPath);
211
- execSync("systemctl --user daemon-reload");
212
- console.log("Systemd timer uninstalled");
200
+ execSync("systemctl daemon-reload");
201
+ console.log("Systemd service uninstalled");
213
202
  }
214
203
  if (options.action === "status") {
215
- execSync("systemctl --user --no-pager status way-backup.timer", { stdio: "inherit" });
204
+ execSync("systemctl --no-pager status way-backup.service", { stdio: "inherit" });
205
+ }
206
+ }
207
+
208
+ // src/commands/daemon.ts
209
+ import cron from "node-cron";
210
+ var isRunning = false;
211
+ var taskQueue = [];
212
+ async function executeTask(task) {
213
+ taskQueue.push(task);
214
+ if (isRunning) return;
215
+ while (taskQueue.length > 0) {
216
+ isRunning = true;
217
+ const nextTask = taskQueue.shift();
218
+ try {
219
+ await nextTask();
220
+ } catch (error) {
221
+ console.error("Task failed:", error);
222
+ }
216
223
  }
224
+ isRunning = false;
225
+ }
226
+ async function daemon(options) {
227
+ const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
228
+ const config = loadConfig(wayDir, options.remote);
229
+ console.log("Way daemon started");
230
+ for (const [name, project] of Object.entries(config.rules.projects)) {
231
+ const schedule = project.schedule || config.rules.defaults?.schedule || "0 */2 * * *";
232
+ cron.schedule(schedule, () => {
233
+ executeTask(async () => {
234
+ console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running backup: ${name}`);
235
+ await run({ remote: options.remote, projects: [name] });
236
+ });
237
+ });
238
+ console.log(`Scheduled backup for ${name}: ${schedule}`);
239
+ }
240
+ const pruneSchedule = config.rules.maintenance?.prune?.schedule;
241
+ if (pruneSchedule) {
242
+ cron.schedule(pruneSchedule, () => {
243
+ executeTask(async () => {
244
+ console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running prune`);
245
+ await gc({ remote: options.remote, dryRun: false });
246
+ });
247
+ });
248
+ console.log(`Scheduled prune: ${pruneSchedule}`);
249
+ }
250
+ const checkSchedule = config.rules.maintenance?.check?.schedule;
251
+ if (checkSchedule) {
252
+ cron.schedule(checkSchedule, () => {
253
+ executeTask(async () => {
254
+ console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running check`);
255
+ });
256
+ });
257
+ console.log(`Scheduled check: ${checkSchedule}`);
258
+ }
259
+ process.on("SIGTERM", () => {
260
+ console.log("Received SIGTERM, shutting down gracefully");
261
+ process.exit(0);
262
+ });
263
+ process.on("SIGINT", () => {
264
+ console.log("Received SIGINT, shutting down gracefully");
265
+ process.exit(0);
266
+ });
217
267
  }
218
268
 
219
269
  // src/cli.ts
220
270
  var program = new Command();
221
- program.name("way").version("0.4.0").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", `
271
+ program.name("way").version("0.5.1").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", `
222
272
  \u793A\u4F8B:
223
273
  $ way run \u6267\u884C\u6240\u6709\u9879\u76EE\u5907\u4EFD
224
274
  $ way run data \u53EA\u5907\u4EFD data \u9879\u76EE
@@ -230,25 +280,24 @@ program.name("way").version("0.4.0").description("\u7B56\u7565\u5907\u4EFD\u5DE5
230
280
 
231
281
  \u6587\u6863: https://github.com/shellus/way
232
282
  `);
233
- program.command("run [project]").description("\u6267\u884C\u5907\u4EFD").allowUnknownOption().allowExcessArguments().action(async (project, options, command) => {
234
- const remote = command.parent.opts().remote;
235
- const allArgs = command.parent.args.slice(1);
236
- if (project?.startsWith("--")) {
237
- await run({ remote, project: void 0, extraArgs: allArgs });
238
- } else {
239
- const extraArgs = project ? allArgs.slice(1) : allArgs;
240
- await run({ remote, project, extraArgs });
241
- }
283
+ program.command("run [projects...]").description("\u6267\u884C\u5907\u4EFD").option("--dry-run", "\u6A21\u62DF\u5907\u4EFD\uFF08\u4E0D\u5B9E\u9645\u5199\u5165\uFF09").allowUnknownOption().allowExcessArguments().action(async function(projects) {
284
+ const remote = this.parent.opts().remote;
285
+ const extraArgs = this.args.filter((a) => a.startsWith("-") && !["--dry-run"].includes(a));
286
+ if (this.opts().dryRun) extraArgs.push("--dry-run");
287
+ await run({ remote, projects: projects.filter((p) => !p.startsWith("-")), extraArgs });
242
288
  });
243
- program.command("gc").description("\u6E05\u7406\u65E7\u5FEB\u7167").allowUnknownOption().allowExcessArguments().action(async function() {
289
+ program.command("gc").description("\u6E05\u7406\u65E7\u5FEB\u7167").option("--dry-run", "\u6A21\u62DF\u6E05\u7406\uFF08\u4E0D\u5B9E\u9645\u5220\u9664\uFF09").action(async function(cmdOptions) {
244
290
  const remote = this.parent.opts().remote;
245
- const extraArgs = this.parent.args.slice(1);
246
- await gc({ remote, extraArgs });
291
+ await gc({ remote, dryRun: cmdOptions.dryRun });
247
292
  });
248
293
  program.command("systemd <action>").description("\u7BA1\u7406 systemd \u5B9A\u65F6\u4EFB\u52A1 (show|install|uninstall|status)").action(async (action, options, command) => {
249
294
  const remote = command.parent.opts().remote;
250
295
  await systemd({ remote, action });
251
296
  });
297
+ program.command("daemon").description("\u542F\u52A8\u5E38\u9A7B\u8FDB\u7A0B\uFF0C\u6309\u914D\u7F6E\u5B9A\u65F6\u6267\u884C\u5907\u4EFD").action(async (options, command) => {
298
+ const remote = command.parent.opts().remote;
299
+ await daemon({ remote });
300
+ });
252
301
  program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").action(() => {
253
302
  const env = Object.entries(process.env).sort(([a], [b]) => a.localeCompare(b));
254
303
  for (const [key, value] of env) {
@@ -0,0 +1,117 @@
1
+ #!/bin/bash
2
+ # 迁移脚本:v0.4.x -> v0.5.0
3
+ # 将旧配置格式转换为新格式
4
+
5
+ set -e
6
+
7
+ WAY_DIR="${WAY_DIR:-$HOME/.way}"
8
+ RULES_FILE="$WAY_DIR/rules.yaml"
9
+ BACKUP_FILE="$WAY_DIR/rules.yaml.v0.4.backup"
10
+
11
+ if [ ! -f "$RULES_FILE" ]; then
12
+ echo "错误: 未找到 $RULES_FILE"
13
+ exit 1
14
+ fi
15
+
16
+ # 备份原配置
17
+ cp "$RULES_FILE" "$BACKUP_FILE"
18
+ echo "已备份原配置到: $BACKUP_FILE"
19
+
20
+ # 检查是否是旧格式
21
+ if ! grep -q "^schedule:" "$RULES_FILE"; then
22
+ echo "配置已是新格式,无需迁移"
23
+ exit 0
24
+ fi
25
+
26
+ # 提取旧配置的值
27
+ OLD_SCHEDULE=$(grep -A2 "^schedule:" "$BACKUP_FILE" | grep -A1 "backup:" | tail -1 | sed 's/.*"\(.*\)".*/\1/')
28
+ OLD_PRUNE=$(grep -A3 "^schedule:" "$BACKUP_FILE" | grep "prune:" | sed 's/.*"\(.*\)".*/\1/')
29
+ OLD_CHECK=$(grep -A4 "^schedule:" "$BACKUP_FILE" | grep "check:" | sed 's/.*"\(.*\)".*/\1/')
30
+
31
+ KEEP_DAILY=$(grep "keep_daily:" "$BACKUP_FILE" | sed 's/.*: \(.*\)/\1/')
32
+ KEEP_WEEKLY=$(grep "keep_weekly:" "$BACKUP_FILE" | sed 's/.*: \(.*\)/\1/')
33
+ KEEP_MONTHLY=$(grep "keep_monthly:" "$BACKUP_FILE" | sed 's/.*: \(.*\)/\1/')
34
+
35
+ # 生成新配置
36
+ cat > "$RULES_FILE" << EOF
37
+ # 备份规则配置 (v0.5.0)
38
+ # 旧配置已备份到: $(basename $BACKUP_FILE)
39
+
40
+ # 全局默认配置
41
+ defaults:
42
+ schedule: "${OLD_SCHEDULE:-0 */2 * * *}"
43
+ retention:
44
+ keep_daily: ${KEEP_DAILY:-7}
45
+ keep_weekly: ${KEEP_WEEKLY:-4}
46
+ keep_monthly: ${KEEP_MONTHLY:-6}
47
+
48
+ EOF
49
+
50
+ # 复制 uptime_kuma 配置
51
+ if grep -q "^uptime_kuma:" "$BACKUP_FILE"; then
52
+ echo "# Uptime Kuma 通知" >> "$RULES_FILE"
53
+ grep -A1 "^uptime_kuma:" "$BACKUP_FILE" >> "$RULES_FILE"
54
+ echo "" >> "$RULES_FILE"
55
+ fi
56
+
57
+ # 复制 projects 配置
58
+ echo "# 备份项目" >> "$RULES_FILE"
59
+ sed -n '/^projects:/,/^global_excludes:/p' "$BACKUP_FILE" | sed '$d' >> "$RULES_FILE"
60
+ echo "" >> "$RULES_FILE"
61
+
62
+ # 复制 global_excludes
63
+ sed -n '/^global_excludes:/,$p' "$BACKUP_FILE" >> "$RULES_FILE"
64
+
65
+ # 添加 maintenance 配置
66
+ if [ -n "$OLD_PRUNE" ] || [ -n "$OLD_CHECK" ]; then
67
+ echo "" >> "$RULES_FILE"
68
+ echo "# 维护任务" >> "$RULES_FILE"
69
+ echo "maintenance:" >> "$RULES_FILE"
70
+ if [ -n "$OLD_PRUNE" ]; then
71
+ echo " prune:" >> "$RULES_FILE"
72
+ echo " schedule: \"$OLD_PRUNE\"" >> "$RULES_FILE"
73
+ fi
74
+ if [ -n "$OLD_CHECK" ]; then
75
+ echo " check:" >> "$RULES_FILE"
76
+ echo " schedule: \"$OLD_CHECK\"" >> "$RULES_FILE"
77
+ fi
78
+ fi
79
+
80
+ echo "✓ 配置迁移完成"
81
+
82
+ # 检测并升级 systemd 服务
83
+ USER_TIMER="$HOME/.config/systemd/user/way-backup.timer"
84
+ USER_SERVICE="$HOME/.config/systemd/user/way-backup.service"
85
+ SYSTEM_SERVICE="/etc/systemd/system/way-backup.service"
86
+
87
+ # 清理旧版 user 级别服务
88
+ if systemctl --user is-active way-backup.timer &>/dev/null || [ -f "$USER_TIMER" ]; then
89
+ echo ""
90
+ echo "检测到旧版 user 级 systemd timer,正在清理..."
91
+ systemctl --user stop way-backup.timer 2>/dev/null || true
92
+ systemctl --user disable way-backup.timer 2>/dev/null || true
93
+ rm -f "$USER_TIMER"
94
+ systemctl --user stop way-backup.service 2>/dev/null || true
95
+ systemctl --user disable way-backup.service 2>/dev/null || true
96
+ rm -f "$USER_SERVICE"
97
+ systemctl --user daemon-reload
98
+ fi
99
+
100
+ if systemctl --user is-active way-backup.service &>/dev/null || [ -f "$USER_SERVICE" ]; then
101
+ echo ""
102
+ echo "检测到旧版 user 级 systemd service,正在清理..."
103
+ systemctl --user stop way-backup.service 2>/dev/null || true
104
+ systemctl --user disable way-backup.service 2>/dev/null || true
105
+ rm -f "$USER_SERVICE"
106
+ systemctl --user daemon-reload
107
+ fi
108
+
109
+ # 安装 system 级别服务
110
+ echo ""
111
+ echo "安装 system 级 systemd service..."
112
+ way systemd install
113
+
114
+ echo "✓ systemd 服务已升级为 system 级别"
115
+
116
+ echo ""
117
+ echo "如需回滚配置: mv $BACKUP_FILE $RULES_FILE"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shellus/way",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
4
4
  "description": "将备份作为持续运营的项目,而非一次性任务。基于 restic 的策略封装。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,8 @@
16
16
  "files": [
17
17
  "dist",
18
18
  "repositories.yaml.example",
19
- "rules.yaml.example"
19
+ "rules.yaml.example",
20
+ "migrate-to-v0.5.sh"
20
21
  ],
21
22
  "repository": {
22
23
  "type": "git",
@@ -43,11 +44,13 @@
43
44
  "dependencies": {
44
45
  "commander": "^14.0.3",
45
46
  "execa": "^9.6.1",
46
- "js-yaml": "^4.1.1"
47
+ "js-yaml": "^4.1.1",
48
+ "node-cron": "^4.2.1"
47
49
  },
48
50
  "devDependencies": {
49
51
  "@types/js-yaml": "^4.0.9",
50
52
  "@types/node": "^25.5.0",
53
+ "@types/node-cron": "^3.0.11",
51
54
  "tsup": "^8.5.1",
52
55
  "typescript": "^5.9.3",
53
56
  "vitest": "^4.1.0"
@@ -2,23 +2,24 @@
2
2
  # 备份目的地见 repositories.yaml
3
3
  # 复制为 rules.yaml 并根据实际情况修改
4
4
 
5
+ # 全局默认配置
6
+ defaults:
7
+ # schedule 使用 node-cron 语法(分 时 日 月 周)
8
+ # 支持多种表达方式:
9
+ # "0 */2 * * *" - 每 2 小时
10
+ # "0 9,15,21 * * *" - 每天 9:00、15:00、21:00
11
+ # "0 9-17 * * 1-5" - 工作日 9:00-17:00 每小时
12
+ schedule: "0 */2 * * *"
13
+ retention:
14
+ keep_daily: 7
15
+ keep_weekly: 4
16
+ keep_monthly: 6
17
+
5
18
  # Uptime Kuma 通知(备份完成后推送状态)
6
19
  uptime_kuma:
7
20
  push_url: "https://uptime.example.com/api/push/xxxxx"
8
21
 
9
- # 备份时间(cron 格式,会自动转换为 systemd OnCalendar)
10
- schedule:
11
- backup:
12
- - "0 12 * * *" # 每天中午 12:00
13
- prune: "0 4 * * 0" # 每周日凌晨 4:00 清理
14
- check: "" # 完整性检查,留空不执行
15
-
16
- # 快照保留策略
17
- retention:
18
- keep_daily: 7
19
- keep_weekly: 4
20
- keep_monthly: 6
21
-
22
+ # 备份项目
22
23
  projects:
23
24
  data:
24
25
  description: 数据目录
@@ -33,6 +34,18 @@ projects:
33
34
  paths:
34
35
  - /home/user/.config
35
36
  - /home/user/.ssh
37
+ schedule: "0 3 * * *" # 覆盖默认,每天凌晨 3 点
38
+ excludes: []
39
+
40
+ archive:
41
+ description: 历史归档
42
+ paths:
43
+ - /archive
44
+ schedule: "0 2 * * 0" # 每周日凌晨 2 点
45
+ retention:
46
+ keep_weekly: 8
47
+ keep_monthly: 12
48
+ keep_yearly: 3
36
49
  excludes: []
37
50
 
38
51
  # 全局排除规则 - 适用于所有项目
@@ -60,3 +73,10 @@ global_excludes:
60
73
  - "*.log"
61
74
  - "*.tmp"
62
75
  - "*.swp"
76
+
77
+ # 维护任务(可选)
78
+ maintenance:
79
+ prune:
80
+ schedule: "0 4 * * 0" # 每周日凌晨 4 点清理
81
+ check:
82
+ schedule: "" # 留空不执行