@shellus/way 0.6.1 → 0.6.10

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
@@ -67,22 +67,40 @@
67
67
 
68
68
  ## 依赖
69
69
 
70
- - Node.js >= 18
70
+ - Linux x64 独立发行包无需预装 Node.js、npm、Bun restic
71
+ - npm 安装方式需要 Node.js >= 18
71
72
  - Linux x64 平台内置 [restic](https://restic.net/) 0.18.1,其他平台需自行安装 restic
72
73
 
73
74
  `way` 查找 restic 的顺序:
74
75
 
75
76
  1. `WAY_RESTIC_BIN` 指定的二进制
76
- 2. Linux x64 包内置的 restic
77
- 3. 系统 `PATH` 中的 `restic`
77
+ 2. Linux x64 npm 包内置的 restic
78
+ 3. Linux x64 独立发行包旁边或安装目录中的 restic
79
+ 4. 系统 `PATH` 中的 `restic`
78
80
 
79
81
  ## 安装
80
82
 
83
+ ### Linux x64 独立发行包(推荐)
84
+
85
+ GitHub Release 提供 Linux x64 独立发行包,安装机无需预装 Node.js、npm、Bun 或 restic:
86
+
87
+ ```bash
88
+ curl -fsSL https://raw.githubusercontent.com/shellus/way/master/scripts/install.sh | sudo sh
89
+ ```
90
+
91
+ 默认安装位置:
92
+
93
+ - `way`: `/usr/local/bin/way`
94
+ - 内置 restic: `/usr/local/lib/way/vendor/restic/linux-x64/restic`
95
+ - 示例配置: `/usr/local/lib/way/*.yaml.example`
96
+
97
+ ### npm 安装
98
+
81
99
  ```bash
82
100
  npm install -g @shellus/way
83
101
  ```
84
102
 
85
- Linux x64 用户无需额外安装 restic。如需使用自定义 restic,可设置:
103
+ Linux x64 安装方式无需额外安装 restic。如需使用自定义 restic,可设置:
86
104
 
87
105
  ```bash
88
106
  WAY_RESTIC_BIN=/usr/local/bin/restic way restic snapshots
@@ -93,9 +111,7 @@ WAY_RESTIC_BIN=/usr/local/bin/restic way restic snapshots
93
111
  ### 1. 初始化配置
94
112
 
95
113
  ```bash
96
- mkdir -p ~/.way
97
- cp $(npm root -g)/@shellus/way/repositories.yaml.example ~/.way/repositories.yaml
98
- cp $(npm root -g)/@shellus/way/rules.yaml.example ~/.way/rules.yaml
114
+ way init
99
115
  ```
100
116
 
101
117
  编辑 `~/.way/repositories.yaml` 填入实际凭证,设置权限:
@@ -108,10 +124,10 @@ chmod 600 ~/.way/repositories.yaml
108
124
 
109
125
  ```bash
110
126
  # 本地仓库
111
- way init
127
+ way restic init
112
128
 
113
129
  # S3 仓库(指定其他仓库)
114
- way --remote=s3 init
130
+ way --remote=s3 restic init
115
131
  ```
116
132
 
117
133
  ### 3. 配置定时备份
@@ -163,6 +179,8 @@ way restic restore abc123 --target /tmp/restore # → restic restore abc123 --t
163
179
  way --remote=oss restic snapshots
164
180
  ```
165
181
 
182
+ `way backup` 会继续执行本轮全部目标项目并输出汇总。全部项目成功时退出码为 `0`;任一项目失败时退出码为 `1`。
183
+
166
184
  ## 生命周期
167
185
 
168
186
  ```mermaid
@@ -184,8 +202,8 @@ graph LR
184
202
  配置文件默认存放在 `~/.way/`,安装后复制示例文件并填入实际值:
185
203
 
186
204
  ```bash
187
- cp ~/.way/repositories.yaml.example ~/.way/repositories.yaml
188
- cp ~/.way/rules.yaml.example ~/.way/rules.yaml
205
+ cp /usr/local/lib/way/repositories.yaml.example ~/.way/repositories.yaml
206
+ cp /usr/local/lib/way/rules.yaml.example ~/.way/rules.yaml
189
207
  ```
190
208
 
191
209
  也可以通过 `WAY_DIR` 环境变量指定其他目录:
@@ -200,19 +218,62 @@ WAY_DIR=/path/to/config way restic snapshots
200
218
 
201
219
  - **defaults**: 全局默认配置(schedule、retention)
202
220
  - **projects**: 备份项目配置,可覆盖默认 schedule 和 retention
221
+ - **projects.*.hooks**: 项目级备份钩子,支持 `before_backup` 和 `after_backup`
222
+ - **uptime_kuma.push_url**: 全局 Uptime Kuma Push 地址,作为项目未配置通知地址时的回退
223
+ - **projects.*.uptime_kuma.push_url**: 项目级 Uptime Kuma Push 地址,优先于全局地址
203
224
  - **maintenance**: 维护任务配置(prune、check)
204
225
  - **global_excludes**: 全局排除规则
205
226
 
227
+ 项目级钩子用于在 restic 备份前后执行一致性快照、校验或清理脚本:
228
+
229
+ ```yaml
230
+ projects:
231
+ data:
232
+ paths:
233
+ - /path/to/data
234
+ hooks:
235
+ before_backup:
236
+ - run: /path/to/scripts/prepare-data-backup.sh
237
+ timeout: "10m"
238
+ after_backup:
239
+ - run: /path/to/scripts/verify-data-backup.sh
240
+ timeout: "5m"
241
+ ```
242
+
243
+ `before_backup` 失败会跳过该项目的 restic 备份并标记项目失败;`after_backup` 只在 restic 成功后执行,失败同样会标记项目失败。`--dry-run` 模式只打印钩子命令,不实际执行。钩子命令按 shell 命令执行,并会收到 `WAY_PROJECT`、`WAY_REMOTE`、`WAY_DIR`、`WAY_DRY_RUN` 环境变量。
244
+
245
+ Linux 上的 hook 达到 `timeout` 后,Way 会终止对应 shell 的完整进程组;进程未在宽限期内退出时会继续强制终止,避免数据库导出、压缩或同步子进程脱离 Way 后持续运行。hook 仍应保持幂等,并自行清理失败时生成的临时文件。
246
+
247
+ **项目级 Uptime Kuma 通知示例**:
248
+
249
+ ```yaml
250
+ uptime_kuma:
251
+ push_url: "https://uptime.example.com/api/push/global-placeholder"
252
+
253
+ projects:
254
+ data:
255
+ paths: [/data]
256
+ uptime_kuma:
257
+ push_url: "https://uptime.example.com/api/push/data-placeholder"
258
+
259
+ root:
260
+ paths: [/root]
261
+ uptime_kuma:
262
+ push_url: "https://uptime.example.com/api/push/system-placeholder"
263
+ ```
264
+
265
+ 每个项目优先使用项目级 Push 地址,未配置时回退到全局地址。一次 `way backup` 中使用相同有效地址的项目只发送一条汇总通知;同组任一项目失败时状态为 DOWN,不同地址的通知互不覆盖。`--dry-run` 不发送通知,通知请求失败只记录错误,不改变备份结果。
266
+
206
267
  **项目级调度示例**:
207
268
 
208
269
  ```yaml
209
270
  defaults:
210
- schedule: "0 */2 * * *" # 默认每 2 小时
271
+ schedule: false # 默认不自动调度
211
272
 
212
273
  projects:
213
274
  data:
214
275
  paths: [/data]
215
- # 继承默认 schedule
276
+ schedule: "0 */2 * * *" # 2 小时
216
277
 
217
278
  logs:
218
279
  paths: [/var/log]
@@ -220,20 +281,57 @@ projects:
220
281
 
221
282
  archive:
222
283
  paths: [/archive]
223
- schedule: "0 2 * * 0" # 每周日凌晨 2
284
+ schedule: false # 只手动触发:way backup archive
224
285
  retention:
225
286
  keep_weekly: 8
226
287
  keep_monthly: 12
227
288
  ```
228
289
 
290
+ **手动备份匹配目录示例**:
291
+
292
+ 日常备份可排除可重建依赖目录,避免遍历海量小文件:
293
+
294
+ ```yaml
295
+ projects:
296
+ data:
297
+ paths: [/data]
298
+ excludes:
299
+ - node_modules
300
+ - vendor
301
+ schedule: "0 */2 * * *"
302
+ ```
303
+
304
+ 如果需要偶尔保留依赖目录,可单独配置只手动触发的项目:
305
+
306
+ ```yaml
307
+ projects:
308
+ data_deps:
309
+ paths: [/data]
310
+ include_dirs:
311
+ - www/xhj/*/node_modules
312
+ - www/xhj/*/vendor
313
+ schedule: false
314
+ ```
315
+
316
+ 手动执行:
317
+
318
+ ```bash
319
+ way backup data_deps
320
+ ```
321
+
322
+ `include_dirs` 是相对 `paths` 的目录 glob,只支持路径段中的 `*`,不支持绝对路径和 `**`。命中目录后,`way` 会把该目录作为备份根路径写入临时 `--files-from` 列表。`include_dirs` 是专用模式,不应用 `global_excludes`,也不能同时配置项目级 `excludes`。
323
+
229
324
  **schedule 语法**(node-cron 格式):
230
325
 
326
+ `schedule` 支持 node-cron 字符串或 `false`。`false` 表示不创建自动调度任务,只能通过 `way backup <project>` 或 `way backup` 手动触发。项目未设置 `schedule` 时继承 `defaults.schedule`;如果全局和项目都未设置,则不自动调度。
327
+
231
328
  | 格式 | 说明 | 示例 |
232
329
  |------|------|------|
233
330
  | `"0 */2 * * *"` | 间隔表达式 | 每 2 小时 |
234
331
  | `"0 9,15,21 * * *"` | 多个时间点(逗号) | 每天 9:00、15:00、21:00 |
235
332
  | `"0 9-17 * * 1-5"` | 时间范围 | 工作日 9:00-17:00 每小时 |
236
333
  | `"*/30 * * * *"` | 分钟间隔 | 每 30 分钟 |
334
+ | `false` | 禁用自动调度 | 只手动备份 |
237
335
 
238
336
  #### 排除规则通配符语法
239
337
 
@@ -364,4 +462,4 @@ way --remote=oss restic restore <snapshot-id> --target /tmp/restore
364
462
  - 变更规范和测试要求
365
463
  - 发布流程
366
464
 
367
- 内部开发参考:[CLAUDE.md](CLAUDE.md)
465
+ 项目维护规则:[AGENTS.md](AGENTS.md)
package/dist/cli.js CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
+ import fs6 from "fs";
6
+ import path6 from "path";
5
7
 
6
8
  // src/core/config.ts
7
9
  import fs from "fs";
@@ -23,6 +25,8 @@ function loadConfig(wayDir, remoteName) {
23
25
 
24
26
  // src/core/restic.ts
25
27
  import { execa } from "execa";
28
+ import fs3 from "fs";
29
+ import path3 from "path";
26
30
 
27
31
  // src/core/restic-bin.ts
28
32
  import fs2 from "fs";
@@ -31,6 +35,34 @@ import { fileURLToPath } from "url";
31
35
  function getBundledResticBin(packageRoot) {
32
36
  return path2.join(packageRoot, "vendor/restic/linux-x64/restic");
33
37
  }
38
+ function getStandaloneResticBinCandidates(executablePath) {
39
+ const binDir = path2.dirname(executablePath);
40
+ const archiveRoot = path2.dirname(binDir);
41
+ const prefixRoot = path2.dirname(binDir);
42
+ return [
43
+ path2.join(archiveRoot, "vendor/restic/linux-x64/restic"),
44
+ path2.join(prefixRoot, "lib/way/vendor/restic/linux-x64/restic")
45
+ ];
46
+ }
47
+ function getExampleConfigCandidates(file, packageRoot, executablePath) {
48
+ const binDir = path2.dirname(executablePath);
49
+ const archiveRoot = path2.dirname(binDir);
50
+ const prefixRoot = path2.dirname(binDir);
51
+ return [
52
+ path2.join(packageRoot, `${file}.example`),
53
+ path2.join(archiveRoot, `${file}.example`),
54
+ path2.join(prefixRoot, "lib/way", `${file}.example`)
55
+ ];
56
+ }
57
+ function resolveExampleConfigPath(file, options = {}) {
58
+ const packageRoot = options.packageRoot ?? findPackageRoot();
59
+ const executablePath = options.executablePath ?? process.execPath;
60
+ const existsSync = options.existsSync ?? fs2.existsSync;
61
+ for (const candidate of getExampleConfigCandidates(file, packageRoot, executablePath)) {
62
+ if (existsSync(candidate)) return candidate;
63
+ }
64
+ throw new Error(`Unable to find ${file}.example`);
65
+ }
34
66
  function findPackageRoot(startDir = path2.dirname(fileURLToPath(import.meta.url))) {
35
67
  let current = startDir;
36
68
  while (true) {
@@ -45,11 +77,15 @@ function resolveResticBin(options = {}) {
45
77
  const platform = options.platform ?? process.platform;
46
78
  const arch = options.arch ?? process.arch;
47
79
  const packageRoot = options.packageRoot ?? findPackageRoot();
80
+ const executablePath = options.executablePath ?? process.execPath;
48
81
  const existsSync = options.existsSync ?? fs2.existsSync;
49
82
  if (env.WAY_RESTIC_BIN) return env.WAY_RESTIC_BIN;
50
83
  if (platform === "linux" && arch === "x64") {
51
84
  const bundled = getBundledResticBin(packageRoot);
52
85
  if (existsSync(bundled)) return bundled;
86
+ for (const candidate of getStandaloneResticBinCandidates(executablePath)) {
87
+ if (existsSync(candidate)) return candidate;
88
+ }
53
89
  }
54
90
  return "restic";
55
91
  }
@@ -73,13 +109,75 @@ function buildResticEnv(repo) {
73
109
  if (repo.credentials.secret_access_key) env.AWS_SECRET_ACCESS_KEY = repo.credentials.secret_access_key;
74
110
  return env;
75
111
  }
76
- function buildBackupArgs(name, project, globalExcludes) {
112
+ function buildBackupArgs(name, project, globalExcludes, filesFrom) {
77
113
  const args = ["backup", `--tag=way:${name}`];
114
+ if (project.include_dirs?.length) {
115
+ if (project.excludes?.length) {
116
+ throw new Error(`Project ${name} cannot use excludes with include_dirs`);
117
+ }
118
+ if (!filesFrom) throw new Error(`Project ${name} uses include_dirs but no files-from list was provided`);
119
+ args.push(`--files-from=${filesFrom}`);
120
+ return args;
121
+ }
78
122
  const allExcludes = [...globalExcludes, ...project.excludes || []];
79
123
  for (const exclude of allExcludes) args.push(`--exclude=${exclude}`);
80
124
  args.push(...project.paths);
81
125
  return args;
82
126
  }
127
+ function collectIncludeDirs(paths, includeDirs, options = {}) {
128
+ const readdirSync = options.readdirSync ?? ((dir) => fs3.readdirSync(dir, { withFileTypes: true }));
129
+ const matches = [];
130
+ function isDirectoryEntry(entry) {
131
+ return typeof entry.isDirectory === "function" ? entry.isDirectory() : entry.isDirectory;
132
+ }
133
+ function readDirectoryEntries(dir) {
134
+ try {
135
+ return readdirSync(dir);
136
+ } catch (error) {
137
+ console.error(`Failed to scan ${dir}:`, error);
138
+ return [];
139
+ }
140
+ }
141
+ function validatePattern(pattern) {
142
+ if (path3.isAbsolute(pattern)) {
143
+ throw new Error(`include_dirs pattern must be relative to project paths: ${pattern}`);
144
+ }
145
+ if (pattern.split("/").includes("**")) {
146
+ throw new Error(`include_dirs does not support **: ${pattern}`);
147
+ }
148
+ }
149
+ function matchSegment(name, pattern) {
150
+ if (pattern === "*") return true;
151
+ if (!pattern.includes("*")) return name === pattern;
152
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
153
+ return new RegExp(`^${escaped}$`).test(name);
154
+ }
155
+ function expandPattern(root, pattern) {
156
+ const segments = pattern.split("/").filter(Boolean);
157
+ let candidates = [path3.resolve(root)];
158
+ for (const segment of segments) {
159
+ const nextCandidates = [];
160
+ for (const candidate of candidates) {
161
+ const entries = readDirectoryEntries(candidate);
162
+ for (const entry of entries) {
163
+ if (!isDirectoryEntry(entry)) continue;
164
+ if (!matchSegment(entry.name, segment)) continue;
165
+ nextCandidates.push(path3.join(candidate, entry.name));
166
+ }
167
+ }
168
+ candidates = nextCandidates;
169
+ if (candidates.length === 0) break;
170
+ }
171
+ return candidates;
172
+ }
173
+ for (const pattern of includeDirs) validatePattern(pattern);
174
+ for (const sourcePath of paths) {
175
+ for (const pattern of includeDirs) {
176
+ matches.push(...expandPattern(sourcePath, pattern));
177
+ }
178
+ }
179
+ return Array.from(new Set(matches));
180
+ }
83
181
  function buildRestoreArgs(name, project, options) {
84
182
  const args = [
85
183
  "restore",
@@ -88,7 +186,7 @@ function buildRestoreArgs(name, project, options) {
88
186
  ];
89
187
  if (options.host) args.push(`--host=${options.host}`);
90
188
  args.push(`--target=${options.target}`);
91
- for (const path4 of project.paths) args.push(`--include=${path4}`);
189
+ for (const path7 of project.paths) args.push(`--include=${path7}`);
92
190
  if (options.dryRun) args.push("--dry-run");
93
191
  if (options.delete) args.push("--delete");
94
192
  if (options.verbose) args.push("--verbose=2");
@@ -115,6 +213,10 @@ async function execRestic(args, env, s3Options = []) {
115
213
  }
116
214
 
117
215
  // src/commands/backup.ts
216
+ import fs4 from "fs";
217
+ import os from "os";
218
+ import path4 from "path";
219
+ import { execaCommand } from "execa";
118
220
  async function backup(options) {
119
221
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
120
222
  const config = loadConfig(wayDir, options.remote);
@@ -126,35 +228,182 @@ async function backup(options) {
126
228
  const extraArgs = options.extraArgs?.filter((arg) => arg !== "--dry-run") || [];
127
229
  const succeeded = [];
128
230
  const failed = [];
231
+ const projectResults = [];
129
232
  const startTime = Date.now();
130
233
  for (const projectName of projects) {
234
+ const projectStartTime = Date.now();
131
235
  const project = config.rules.projects[projectName];
236
+ const pushUrl = resolveUptimeKumaPushUrl(project, config.rules.uptime_kuma?.push_url);
132
237
  if (!project) {
133
238
  console.error(`Project not found: ${projectName}`);
134
239
  failed.push(projectName);
240
+ projectResults.push({
241
+ succeeded: [],
242
+ failed: [projectName],
243
+ duration: Date.now() - projectStartTime,
244
+ pushUrl
245
+ });
135
246
  continue;
136
247
  }
137
248
  console.log(`=== Backing up: ${projectName} ===`);
249
+ let filesFrom;
250
+ let projectSucceeded = false;
138
251
  try {
139
- const args = buildBackupArgs(projectName, project, globalExcludes);
252
+ await runProjectHooks(project.hooks?.before_backup, {
253
+ projectName,
254
+ remote: options.remote,
255
+ wayDir,
256
+ dryRun,
257
+ label: "before_backup"
258
+ });
259
+ if (project.include_dirs?.length) {
260
+ const includedDirs = collectIncludeDirs(project.paths, project.include_dirs);
261
+ if (includedDirs.length === 0) {
262
+ console.log(`No include_dirs matched for ${projectName}`);
263
+ succeeded.push(projectName);
264
+ projectSucceeded = true;
265
+ continue;
266
+ }
267
+ filesFrom = path4.join(os.tmpdir(), `way-${projectName}-${Date.now()}-${process.pid}.files`);
268
+ fs4.writeFileSync(filesFrom, `${includedDirs.join("\n")}
269
+ `);
270
+ }
271
+ const args = buildBackupArgs(projectName, project, globalExcludes, filesFrom);
140
272
  if (dryRun) args.push("--dry-run");
141
273
  args.push(...extraArgs);
142
274
  await execRestic(args, env, s3Options);
275
+ await runProjectHooks(project.hooks?.after_backup, {
276
+ projectName,
277
+ remote: options.remote,
278
+ wayDir,
279
+ dryRun,
280
+ label: "after_backup"
281
+ });
143
282
  succeeded.push(projectName);
283
+ projectSucceeded = true;
144
284
  } catch (error) {
145
285
  console.error(`Failed to backup ${projectName}:`, error);
146
286
  failed.push(projectName);
287
+ } finally {
288
+ if (filesFrom) fs4.rmSync(filesFrom, { force: true });
289
+ projectResults.push({
290
+ succeeded: projectSucceeded ? [projectName] : [],
291
+ failed: projectSucceeded ? [] : [projectName],
292
+ duration: Date.now() - projectStartTime,
293
+ pushUrl
294
+ });
147
295
  }
148
296
  }
149
297
  const duration = Date.now() - startTime;
150
298
  console.log("\n=== Summary ===");
151
299
  if (succeeded.length > 0) console.log("Succeeded:", succeeded.join(", "));
152
300
  if (failed.length > 0) console.log("Failed:", failed.join(", "));
153
- if (!dryRun && config.rules.uptime_kuma?.push_url) {
154
- await notifyUptimeKuma({ succeeded, failed, duration }, config.rules.uptime_kuma.push_url);
301
+ if (!dryRun) {
302
+ for (const [pushUrl, result] of groupUptimeKumaResults(projectResults)) {
303
+ await notifyUptimeKuma(result, pushUrl);
304
+ }
155
305
  }
156
306
  return { succeeded, failed, duration };
157
307
  }
308
+ function resolveUptimeKumaPushUrl(project, globalPushUrl) {
309
+ return project?.uptime_kuma?.push_url || globalPushUrl;
310
+ }
311
+ function groupUptimeKumaResults(projectResults) {
312
+ const groupedResults = /* @__PURE__ */ new Map();
313
+ for (const projectResult of projectResults) {
314
+ if (!projectResult.pushUrl) continue;
315
+ const result = groupedResults.get(projectResult.pushUrl) || {
316
+ succeeded: [],
317
+ failed: [],
318
+ duration: 0
319
+ };
320
+ result.succeeded.push(...projectResult.succeeded);
321
+ result.failed.push(...projectResult.failed);
322
+ result.duration += projectResult.duration;
323
+ groupedResults.set(projectResult.pushUrl, result);
324
+ }
325
+ return groupedResults;
326
+ }
327
+ var HOOK_FORCE_KILL_DELAY_MS = 1e3;
328
+ function normalizeHook(hook) {
329
+ if (typeof hook === "string") return { run: hook };
330
+ return hook;
331
+ }
332
+ function parseTimeout(timeout) {
333
+ if (timeout === void 0) return void 0;
334
+ if (typeof timeout === "number") return timeout;
335
+ const match = timeout.match(/^(\d+)(ms|s|m|h)?$/);
336
+ if (!match) throw new Error(`Invalid hook timeout: ${timeout}`);
337
+ const value = Number(match[1]);
338
+ const unit = match[2] || "ms";
339
+ switch (unit) {
340
+ case "ms":
341
+ return value;
342
+ case "s":
343
+ return value * 1e3;
344
+ case "m":
345
+ return value * 60 * 1e3;
346
+ case "h":
347
+ return value * 60 * 60 * 1e3;
348
+ }
349
+ }
350
+ function isMissingProcessError(error) {
351
+ return error instanceof Error && "code" in error && error.code === "ESRCH";
352
+ }
353
+ function signalProcessGroup(pid, signal) {
354
+ try {
355
+ process.kill(-pid, signal);
356
+ return true;
357
+ } catch (error) {
358
+ if (isMissingProcessError(error)) return false;
359
+ throw error;
360
+ }
361
+ }
362
+ function hookTimedOut(error) {
363
+ return typeof error === "object" && error !== null && "timedOut" in error && error.timedOut === true;
364
+ }
365
+ async function terminateProcessGroup(pid) {
366
+ if (!signalProcessGroup(pid, "SIGTERM")) return;
367
+ await new Promise((resolve) => setTimeout(resolve, HOOK_FORCE_KILL_DELAY_MS));
368
+ signalProcessGroup(pid, "SIGKILL");
369
+ }
370
+ async function runProjectHooks(hooks, context) {
371
+ if (!hooks?.length) return;
372
+ for (const hook of hooks) {
373
+ const normalized = normalizeHook(hook);
374
+ if (!normalized.run) throw new Error(`Project ${context.projectName} has an empty ${context.label} hook`);
375
+ if (context.dryRun) {
376
+ console.log(`[dry-run] ${context.label}: ${normalized.run}`);
377
+ continue;
378
+ }
379
+ console.log(`Running ${context.label} hook for ${context.projectName}: ${normalized.run}`);
380
+ const subprocess = execaCommand(normalized.run, {
381
+ shell: true,
382
+ stdio: "inherit",
383
+ timeout: parseTimeout(normalized.timeout),
384
+ detached: process.platform !== "win32",
385
+ env: {
386
+ WAY_PROJECT: context.projectName,
387
+ WAY_REMOTE: context.remote,
388
+ WAY_DIR: context.wayDir,
389
+ WAY_DRY_RUN: context.dryRun ? "1" : "0"
390
+ }
391
+ });
392
+ const processGroupPid = process.platform === "win32" ? void 0 : subprocess.pid;
393
+ const cleanupOnExit = processGroupPid === void 0 ? void 0 : () => signalProcessGroup(processGroupPid, "SIGTERM");
394
+ if (cleanupOnExit) process.once("exit", cleanupOnExit);
395
+ try {
396
+ await subprocess;
397
+ } catch (error) {
398
+ if (processGroupPid !== void 0 && hookTimedOut(error)) {
399
+ await terminateProcessGroup(processGroupPid);
400
+ }
401
+ throw error;
402
+ } finally {
403
+ if (cleanupOnExit) process.removeListener("exit", cleanupOnExit);
404
+ }
405
+ }
406
+ }
158
407
  async function notifyUptimeKuma(result, pushUrl) {
159
408
  const status = result.failed.length > 0 ? "down" : "up";
160
409
  const msg = `Succeeded: ${result.succeeded.length}, Failed: ${result.failed.length}`;
@@ -240,12 +489,22 @@ async function gc(options) {
240
489
 
241
490
  // src/commands/systemd.ts
242
491
  import { execSync } from "child_process";
243
- import fs3 from "fs";
244
- import path3 from "path";
492
+ import fs5 from "fs";
493
+ import path5 from "path";
494
+ function resolveWayCommandPath(options = {}) {
495
+ const env = options.env ?? process.env;
496
+ const argv = options.argv ?? process.argv;
497
+ const execPath = options.execPath ?? process.execPath;
498
+ const whichWay = options.whichWay ?? (() => execSync("which way", { encoding: "utf-8" }).trim());
499
+ 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];
502
+ return whichWay();
503
+ }
245
504
  async function systemd(options) {
246
505
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
247
506
  const config = loadConfig(wayDir, options.remote);
248
- const wayPath = execSync("which way", { encoding: "utf-8" }).trim();
507
+ const wayPath = resolveWayCommandPath();
249
508
  const currentUser = execSync("whoami", { encoding: "utf-8" }).trim();
250
509
  const serviceContent = `[Unit]
251
510
  Description=Way Backup Daemon
@@ -269,9 +528,9 @@ WantedBy=multi-user.target
269
528
  return;
270
529
  }
271
530
  const systemdDir = "/etc/systemd/system";
272
- const servicePath = path3.join(systemdDir, "way-backup.service");
531
+ const servicePath = path5.join(systemdDir, "way-backup.service");
273
532
  if (options.action === "install") {
274
- fs3.writeFileSync(servicePath, serviceContent);
533
+ fs5.writeFileSync(servicePath, serviceContent);
275
534
  execSync("systemctl daemon-reload");
276
535
  execSync("systemctl enable way-backup.service");
277
536
  execSync("systemctl start way-backup.service");
@@ -287,7 +546,7 @@ WantedBy=multi-user.target
287
546
  execSync("systemctl disable way-backup.service", { stdio: "ignore" });
288
547
  } catch {
289
548
  }
290
- if (fs3.existsSync(servicePath)) fs3.unlinkSync(servicePath);
549
+ if (fs5.existsSync(servicePath)) fs5.unlinkSync(servicePath);
291
550
  execSync("systemctl daemon-reload");
292
551
  console.log("Systemd service uninstalled");
293
552
  }
@@ -300,6 +559,23 @@ WantedBy=multi-user.target
300
559
  import cron from "node-cron";
301
560
  var isRunning = false;
302
561
  var taskQueue = [];
562
+ function resolveProjectSchedule(project, defaults = {}) {
563
+ if (project.schedule !== void 0) return project.schedule;
564
+ return defaults?.schedule ?? false;
565
+ }
566
+ function isEnabledSchedule(schedule) {
567
+ return typeof schedule === "string";
568
+ }
569
+ function assertValidSchedule(schedule, label) {
570
+ if (schedule === "") {
571
+ throw new Error(`${label} must be a cron expression or false. Use false to disable scheduling.`);
572
+ }
573
+ }
574
+ function addScheduledBackup(scheduledBackups, schedule, projectName) {
575
+ const projects = scheduledBackups.get(schedule) || [];
576
+ projects.push(projectName);
577
+ scheduledBackups.set(schedule, projects);
578
+ }
303
579
  async function executeTask(task) {
304
580
  taskQueue.push(task);
305
581
  if (isRunning) return;
@@ -318,18 +594,28 @@ async function daemon(options) {
318
594
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
319
595
  const config = loadConfig(wayDir, options.remote);
320
596
  console.log("Way daemon started");
597
+ const scheduledBackups = /* @__PURE__ */ new Map();
321
598
  for (const [name, project] of Object.entries(config.rules.projects)) {
322
- const schedule = project.schedule || config.rules.defaults?.schedule || "0 */2 * * *";
599
+ const schedule = resolveProjectSchedule(project, config.rules.defaults);
600
+ assertValidSchedule(schedule, `projects.${name}.schedule`);
601
+ if (!isEnabledSchedule(schedule)) {
602
+ console.log(`Skipped scheduled backup for ${name}`);
603
+ continue;
604
+ }
605
+ addScheduledBackup(scheduledBackups, schedule, name);
606
+ }
607
+ for (const [schedule, projects] of scheduledBackups.entries()) {
323
608
  cron.schedule(schedule, () => {
324
609
  executeTask(async () => {
325
- console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running backup: ${name}`);
326
- await backup({ remote: options.remote, projects: [name] });
610
+ console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running backup: ${projects.join(", ")}`);
611
+ await backup({ remote: options.remote, projects });
327
612
  });
328
613
  });
329
- console.log(`Scheduled backup for ${name}: ${schedule}`);
614
+ console.log(`Scheduled backup for ${projects.join(", ")}: ${schedule}`);
330
615
  }
331
616
  const pruneSchedule = config.rules.maintenance?.prune?.schedule;
332
- if (pruneSchedule) {
617
+ assertValidSchedule(pruneSchedule, "maintenance.prune.schedule");
618
+ if (isEnabledSchedule(pruneSchedule)) {
333
619
  cron.schedule(pruneSchedule, () => {
334
620
  executeTask(async () => {
335
621
  console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running prune`);
@@ -339,7 +625,8 @@ async function daemon(options) {
339
625
  console.log(`Scheduled prune: ${pruneSchedule}`);
340
626
  }
341
627
  const checkSchedule = config.rules.maintenance?.check?.schedule;
342
- if (checkSchedule) {
628
+ assertValidSchedule(checkSchedule, "maintenance.check.schedule");
629
+ if (isEnabledSchedule(checkSchedule)) {
343
630
  cron.schedule(checkSchedule, () => {
344
631
  executeTask(async () => {
345
632
  console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running check`);
@@ -359,30 +646,67 @@ async function daemon(options) {
359
646
 
360
647
  // src/cli.ts
361
648
  var program = new Command();
362
- program.name("way").version("0.6.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", `
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", `
363
650
  \u793A\u4F8B:
364
651
  $ way backup \u6267\u884C\u6240\u6709\u9879\u76EE\u5907\u4EFD
365
652
  $ way backup data \u53EA\u5907\u4EFD data \u9879\u76EE
366
653
  $ way backup --dry-run \u6A21\u62DF\u5907\u4EFD\uFF08\u4E0D\u5B9E\u9645\u5199\u5165\uFF09
367
654
  $ way restore data --target /tmp/restore --dry-run
655
+ $ way --remote=oss backup \u4F7F\u7528 oss \u4ED3\u5E93\u6267\u884C\u5907\u4EFD\uFF08\u5168\u5C40\u9009\u9879\u9700\u653E\u5728\u5B50\u547D\u4EE4\u524D\uFF09
656
+ $ way init \u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6
368
657
  $ way gc \u6E05\u7406\u65E7\u5FEB\u7167
369
658
  $ way systemd install \u5B89\u88C5\u5B9A\u65F6\u4EFB\u52A1
370
659
  $ way restic snapshots \u67E5\u770B\u5FEB\u7167\u5217\u8868
371
660
  $ way restic restore abc123 --target /tmp/restore
372
661
  $ way --remote=s3 restic snapshots \u4F7F\u7528 s3 \u4ED3\u5E93
373
662
 
663
+ \u73AF\u5883\u53D8\u91CF:
664
+ WAY_DIR=/path/to/config \u6307\u5B9A\u914D\u7F6E\u76EE\u5F55\uFF08\u9ED8\u8BA4: ~/.way\uFF09
665
+ WAY_RESTIC_BIN=/path/restic \u6307\u5B9A restic \u4E8C\u8FDB\u5236\uFF0C\u4F18\u5148\u4E8E\u5185\u7F6E restic \u548C PATH
666
+
374
667
  \u6587\u6863: https://github.com/shellus/way
375
668
  `);
376
669
  function collectBackupArgs(command) {
377
670
  return command.args.filter((a) => a.startsWith("-") && !["--dry-run"].includes(a));
378
671
  }
379
- 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").allowUnknownOption().allowExcessArguments().action(async function(projects) {
672
+ var commonHelpText = `
673
+ \u5168\u5C40\u7528\u6CD5:
674
+ way --remote=oss <command> ... \u6307\u5B9A\u4ED3\u5E93\uFF08\u5168\u5C40\u9009\u9879\u9700\u653E\u5728\u5B50\u547D\u4EE4\u524D\uFF09
675
+
676
+ \u73AF\u5883\u53D8\u91CF:
677
+ WAY_DIR=/path/to/config \u6307\u5B9A\u914D\u7F6E\u76EE\u5F55\uFF08\u9ED8\u8BA4: ~/.way\uFF09
678
+ WAY_RESTIC_BIN=/path/restic \u6307\u5B9A restic \u4E8C\u8FDB\u5236\uFF0C\u4F18\u5148\u4E8E\u5185\u7F6E restic \u548C PATH
679
+ `;
680
+ program.command("init").description("\u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6").addHelpText("after", commonHelpText).action(() => {
681
+ const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
682
+ const files = ["repositories.yaml", "rules.yaml"];
683
+ fs6.mkdirSync(wayDir, { recursive: true });
684
+ for (const file of files) {
685
+ const target = path6.join(wayDir, file);
686
+ if (fs6.existsSync(target)) {
687
+ throw new Error(`${target} already exists, aborting to avoid overwriting existing config.`);
688
+ }
689
+ }
690
+ for (const file of files) {
691
+ const source = resolveExampleConfigPath(file);
692
+ const target = path6.join(wayDir, file);
693
+ fs6.copyFileSync(source, target);
694
+ console.log(`Created ${target}`);
695
+ }
696
+ });
697
+ 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) {
380
698
  const remote = this.parent.opts().remote;
381
699
  const dryRun = this.opts().dryRun;
382
700
  const extraArgs = collectBackupArgs(this);
383
- await backup({ remote, projects: projects.filter((p) => !p.startsWith("-")), extraArgs, dryRun });
701
+ const result = await backup({
702
+ remote,
703
+ projects: projects.filter((p) => !p.startsWith("-")),
704
+ extraArgs,
705
+ dryRun
706
+ });
707
+ if (result.failed.length > 0) process.exitCode = 1;
384
708
  });
385
- 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").action(async function(projects, cmdOptions) {
709
+ 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) {
386
710
  const remote = this.parent.opts().remote;
387
711
  await restore({
388
712
  remote,
@@ -395,25 +719,25 @@ program.command("restore [projects...]").description("\u6309 rules.yaml \u6062\u
395
719
  verbose: cmdOptions.verbose
396
720
  });
397
721
  });
398
- 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) {
722
+ 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) {
399
723
  const remote = this.parent.opts().remote;
400
724
  await gc({ remote, dryRun: cmdOptions.dryRun });
401
725
  });
402
- program.command("systemd <action>").description("\u7BA1\u7406 systemd \u5B9A\u65F6\u4EFB\u52A1 (show|install|uninstall|status)").action(async (action, options, command) => {
726
+ program.command("systemd <action>").description("\u7BA1\u7406 systemd \u5B9A\u65F6\u4EFB\u52A1 (show|install|uninstall|status)").addHelpText("after", commonHelpText).action(async (action, options, command) => {
403
727
  const remote = command.parent.opts().remote;
404
728
  await systemd({ remote, action });
405
729
  });
406
- program.command("daemon").description("\u542F\u52A8\u5E38\u9A7B\u8FDB\u7A0B\uFF0C\u6309\u914D\u7F6E\u5B9A\u65F6\u6267\u884C\u5907\u4EFD").action(async (options, command) => {
730
+ 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) => {
407
731
  const remote = command.parent.opts().remote;
408
732
  await daemon({ remote });
409
733
  });
410
- program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").action(() => {
734
+ program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").addHelpText("after", commonHelpText).action(() => {
411
735
  const env = Object.entries(process.env).sort(([a], [b]) => a.localeCompare(b));
412
736
  for (const [key, value] of env) {
413
737
  console.log(`${key}=${value}`);
414
738
  }
415
739
  });
416
- program.command("restic [args...]").description("\u663E\u5F0F\u900F\u4F20\u7ED9 restic").allowUnknownOption().allowExcessArguments().action(async function(args) {
740
+ program.command("restic [args...]").description("\u663E\u5F0F\u900F\u4F20\u7ED9 restic").addHelpText("after", commonHelpText).allowUnknownOption().allowExcessArguments().action(async function(args) {
417
741
  const remote = this.parent.opts().remote;
418
742
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
419
743
  const config = loadConfig(wayDir, remote);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shellus/way",
3
- "version": "0.6.1",
3
+ "version": "0.6.10",
4
4
  "description": "将备份作为持续运营的项目,而非一次性任务。基于 restic 的策略封装。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,8 @@
8
8
  },
9
9
  "scripts": {
10
10
  "build": "tsup src/cli.ts --format esm --clean",
11
+ "build:standalone": "bun build src/cli.ts --compile --target=bun-linux-x64-baseline --outfile dist/standalone/way",
12
+ "package:linux-x64": "npm run build:standalone && node scripts/package-release.mjs",
11
13
  "dev": "tsup src/cli.ts --format esm --watch",
12
14
  "test": "vitest",
13
15
  "test:run": "vitest run",
@@ -5,19 +5,20 @@
5
5
  # 全局默认配置
6
6
  defaults:
7
7
  # schedule 使用 node-cron 语法(分 时 日 月 周)
8
+ # 设置为 false 表示不自动调度,只允许手动执行 way backup
8
9
  # 支持多种表达方式:
9
10
  # "0 */2 * * *" - 每 2 小时
10
11
  # "0 9,15,21 * * *" - 每天 9:00、15:00、21:00
11
12
  # "0 9-17 * * 1-5" - 工作日 9:00-17:00 每小时
12
- schedule: "0 */2 * * *"
13
+ schedule: false
13
14
  retention:
14
15
  keep_daily: 7
15
16
  keep_weekly: 4
16
17
  keep_monthly: 6
17
18
 
18
- # Uptime Kuma 通知(备份完成后推送状态)
19
+ # Uptime Kuma 全局通知(可选,未配置项目级地址时作为回退)
19
20
  uptime_kuma:
20
- push_url: "https://uptime.example.com/api/push/xxxxx"
21
+ push_url: "https://uptime.example.com/api/push/global-placeholder"
21
22
 
22
23
  # 备份项目
23
24
  projects:
@@ -25,6 +26,17 @@ projects:
25
26
  description: 数据目录
26
27
  paths:
27
28
  - /path/to/data
29
+ schedule: "0 */2 * * *" # 每 2 小时自动备份
30
+ # 项目级地址优先于全局地址;相同地址的项目会合并为一条通知
31
+ uptime_kuma:
32
+ push_url: "https://uptime.example.com/api/push/data-placeholder"
33
+ hooks:
34
+ before_backup:
35
+ - run: /path/to/scripts/prepare-data-backup.sh
36
+ timeout: "10m"
37
+ after_backup:
38
+ - run: /path/to/scripts/verify-data-backup.sh
39
+ timeout: "5m"
28
40
  excludes:
29
41
  - cache
30
42
  - tmp
@@ -41,13 +53,22 @@ projects:
41
53
  description: 历史归档
42
54
  paths:
43
55
  - /archive
44
- schedule: "0 2 * * 0" # 每周日凌晨 2
56
+ schedule: false # 只手动触发:way backup archive
45
57
  retention:
46
58
  keep_weekly: 8
47
59
  keep_monthly: 12
48
60
  keep_yearly: 3
49
61
  excludes: []
50
62
 
63
+ data_deps:
64
+ description: 手动备份 /path/to/data 下所有依赖目录
65
+ paths:
66
+ - /path/to/data
67
+ include_dirs:
68
+ - www/xhj/*/node_modules
69
+ - www/xhj/*/vendor
70
+ schedule: false # 只手动触发:way backup data_deps
71
+
51
72
  # 全局排除规则 - 适用于所有项目
52
73
  global_excludes:
53
74
  # 包管理
@@ -79,4 +100,4 @@ maintenance:
79
100
  prune:
80
101
  schedule: "0 4 * * 0" # 每周日凌晨 4 点清理
81
102
  check:
82
- schedule: "" # 留空不执行
103
+ schedule: false # 不执行