@shellus/way 0.6.0 → 0.6.9

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. 配置定时备份
@@ -139,7 +155,7 @@ way backup data # 只备份 data 项目
139
155
  way backup data config # 备份多个项目
140
156
  way backup --dry-run # 模拟备份(不实际写入)
141
157
  way restore data --target /tmp/restore # 按规则恢复 data 项目
142
- way restore data --target /tmp/restore --dry-run # 模拟恢复
158
+ way restore data --host old-host --target /tmp/restore --dry-run # 从指定 host 模拟恢复
143
159
  way restore data --target /tmp/restore --delete # 删除目标中快照不存在的文件
144
160
  way gc # 按 retention 策略清理旧快照
145
161
  way gc --dry-run # 模拟清理(不实际删除)
@@ -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,60 @@ 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
+ **项目级 Uptime Kuma 通知示例**:
246
+
247
+ ```yaml
248
+ uptime_kuma:
249
+ push_url: "https://uptime.example.com/api/push/global-placeholder"
250
+
251
+ projects:
252
+ data:
253
+ paths: [/data]
254
+ uptime_kuma:
255
+ push_url: "https://uptime.example.com/api/push/data-placeholder"
256
+
257
+ root:
258
+ paths: [/root]
259
+ uptime_kuma:
260
+ push_url: "https://uptime.example.com/api/push/system-placeholder"
261
+ ```
262
+
263
+ 每个项目优先使用项目级 Push 地址,未配置时回退到全局地址。一次 `way backup` 中使用相同有效地址的项目只发送一条汇总通知;同组任一项目失败时状态为 DOWN,不同地址的通知互不覆盖。`--dry-run` 不发送通知,通知请求失败只记录错误,不改变备份结果。
264
+
206
265
  **项目级调度示例**:
207
266
 
208
267
  ```yaml
209
268
  defaults:
210
- schedule: "0 */2 * * *" # 默认每 2 小时
269
+ schedule: false # 默认不自动调度
211
270
 
212
271
  projects:
213
272
  data:
214
273
  paths: [/data]
215
- # 继承默认 schedule
274
+ schedule: "0 */2 * * *" # 2 小时
216
275
 
217
276
  logs:
218
277
  paths: [/var/log]
@@ -220,20 +279,57 @@ projects:
220
279
 
221
280
  archive:
222
281
  paths: [/archive]
223
- schedule: "0 2 * * 0" # 每周日凌晨 2
282
+ schedule: false # 只手动触发:way backup archive
224
283
  retention:
225
284
  keep_weekly: 8
226
285
  keep_monthly: 12
227
286
  ```
228
287
 
288
+ **手动备份匹配目录示例**:
289
+
290
+ 日常备份可排除可重建依赖目录,避免遍历海量小文件:
291
+
292
+ ```yaml
293
+ projects:
294
+ data:
295
+ paths: [/data]
296
+ excludes:
297
+ - node_modules
298
+ - vendor
299
+ schedule: "0 */2 * * *"
300
+ ```
301
+
302
+ 如果需要偶尔保留依赖目录,可单独配置只手动触发的项目:
303
+
304
+ ```yaml
305
+ projects:
306
+ data_deps:
307
+ paths: [/data]
308
+ include_dirs:
309
+ - www/xhj/*/node_modules
310
+ - www/xhj/*/vendor
311
+ schedule: false
312
+ ```
313
+
314
+ 手动执行:
315
+
316
+ ```bash
317
+ way backup data_deps
318
+ ```
319
+
320
+ `include_dirs` 是相对 `paths` 的目录 glob,只支持路径段中的 `*`,不支持绝对路径和 `**`。命中目录后,`way` 会把该目录作为备份根路径写入临时 `--files-from` 列表。`include_dirs` 是专用模式,不应用 `global_excludes`,也不能同时配置项目级 `excludes`。
321
+
229
322
  **schedule 语法**(node-cron 格式):
230
323
 
324
+ `schedule` 支持 node-cron 字符串或 `false`。`false` 表示不创建自动调度任务,只能通过 `way backup <project>` 或 `way backup` 手动触发。项目未设置 `schedule` 时继承 `defaults.schedule`;如果全局和项目都未设置,则不自动调度。
325
+
231
326
  | 格式 | 说明 | 示例 |
232
327
  |------|------|------|
233
328
  | `"0 */2 * * *"` | 间隔表达式 | 每 2 小时 |
234
329
  | `"0 9,15,21 * * *"` | 多个时间点(逗号) | 每天 9:00、15:00、21:00 |
235
330
  | `"0 9-17 * * 1-5"` | 时间范围 | 工作日 9:00-17:00 每小时 |
236
331
  | `"*/30 * * * *"` | 分钟间隔 | 每 30 分钟 |
332
+ | `false` | 禁用自动调度 | 只手动备份 |
237
333
 
238
334
  #### 排除规则通配符语法
239
335
 
@@ -326,14 +422,14 @@ way restore --target /tmp/restore
326
422
  way restore data --snapshot abc123 --target /tmp/restore
327
423
 
328
424
  # 预演恢复,不实际写入
329
- way restore data --target /tmp/restore --dry-run
425
+ way restore data --host old-host --target /tmp/restore --dry-run
330
426
 
331
427
  # 让目标目录与快照一致,先 dry-run 核对删除清单
332
- way restore data --target /tmp/restore --delete --dry-run
333
- way restore data --target /tmp/restore --delete
428
+ way restore data --host old-host --target /tmp/restore --delete --dry-run
429
+ way restore data --host old-host --target /tmp/restore --delete
334
430
  ```
335
431
 
336
- `way restore` 会为项目自动添加 `--tag=way:<project>` 和项目 `paths` 对应的 `--include` 参数。
432
+ `way restore` 会为项目自动添加 `--tag=way:<project>` 和项目 `paths` 对应的 `--include` 参数;传入 `--host` 时会只恢复指定 host 的快照。
337
433
 
338
434
  ### 4. 原始 restic 恢复
339
435
 
@@ -364,4 +460,4 @@ way --remote=oss restic restore <snapshot-id> --target /tmp/restore
364
460
  - 变更规范和测试要求
365
461
  - 发布流程
366
462
 
367
- 内部开发参考:[CLAUDE.md](CLAUDE.md)
463
+ 内部开发参考:[CLAUDE.md](CLAUDE.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,21 +109,84 @@ 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",
86
184
  options.snapshot || "latest",
87
- `--tag=way:${name}`,
88
- `--target=${options.target}`
185
+ `--tag=way:${name}`
89
186
  ];
90
- for (const path4 of project.paths) args.push(`--include=${path4}`);
187
+ if (options.host) args.push(`--host=${options.host}`);
188
+ args.push(`--target=${options.target}`);
189
+ for (const path7 of project.paths) args.push(`--include=${path7}`);
91
190
  if (options.dryRun) args.push("--dry-run");
92
191
  if (options.delete) args.push("--delete");
93
192
  if (options.verbose) args.push("--verbose=2");
@@ -114,6 +213,10 @@ async function execRestic(args, env, s3Options = []) {
114
213
  }
115
214
 
116
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";
117
220
  async function backup(options) {
118
221
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
119
222
  const config = loadConfig(wayDir, options.remote);
@@ -125,35 +228,147 @@ async function backup(options) {
125
228
  const extraArgs = options.extraArgs?.filter((arg) => arg !== "--dry-run") || [];
126
229
  const succeeded = [];
127
230
  const failed = [];
231
+ const projectResults = [];
128
232
  const startTime = Date.now();
129
233
  for (const projectName of projects) {
234
+ const projectStartTime = Date.now();
130
235
  const project = config.rules.projects[projectName];
236
+ const pushUrl = resolveUptimeKumaPushUrl(project, config.rules.uptime_kuma?.push_url);
131
237
  if (!project) {
132
238
  console.error(`Project not found: ${projectName}`);
133
239
  failed.push(projectName);
240
+ projectResults.push({
241
+ succeeded: [],
242
+ failed: [projectName],
243
+ duration: Date.now() - projectStartTime,
244
+ pushUrl
245
+ });
134
246
  continue;
135
247
  }
136
248
  console.log(`=== Backing up: ${projectName} ===`);
249
+ let filesFrom;
250
+ let projectSucceeded = false;
137
251
  try {
138
- 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);
139
272
  if (dryRun) args.push("--dry-run");
140
273
  args.push(...extraArgs);
141
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
+ });
142
282
  succeeded.push(projectName);
283
+ projectSucceeded = true;
143
284
  } catch (error) {
144
285
  console.error(`Failed to backup ${projectName}:`, error);
145
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
+ });
146
295
  }
147
296
  }
148
297
  const duration = Date.now() - startTime;
149
298
  console.log("\n=== Summary ===");
150
299
  if (succeeded.length > 0) console.log("Succeeded:", succeeded.join(", "));
151
300
  if (failed.length > 0) console.log("Failed:", failed.join(", "));
152
- if (!dryRun && config.rules.uptime_kuma?.push_url) {
153
- 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
+ }
154
305
  }
155
306
  return { succeeded, failed, duration };
156
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
+ function normalizeHook(hook) {
328
+ if (typeof hook === "string") return { run: hook };
329
+ return hook;
330
+ }
331
+ function parseTimeout(timeout) {
332
+ if (timeout === void 0) return void 0;
333
+ if (typeof timeout === "number") return timeout;
334
+ const match = timeout.match(/^(\d+)(ms|s|m|h)?$/);
335
+ if (!match) throw new Error(`Invalid hook timeout: ${timeout}`);
336
+ const value = Number(match[1]);
337
+ const unit = match[2] || "ms";
338
+ switch (unit) {
339
+ case "ms":
340
+ return value;
341
+ case "s":
342
+ return value * 1e3;
343
+ case "m":
344
+ return value * 60 * 1e3;
345
+ case "h":
346
+ return value * 60 * 60 * 1e3;
347
+ }
348
+ }
349
+ async function runProjectHooks(hooks, context) {
350
+ if (!hooks?.length) return;
351
+ for (const hook of hooks) {
352
+ const normalized = normalizeHook(hook);
353
+ if (!normalized.run) throw new Error(`Project ${context.projectName} has an empty ${context.label} hook`);
354
+ if (context.dryRun) {
355
+ console.log(`[dry-run] ${context.label}: ${normalized.run}`);
356
+ continue;
357
+ }
358
+ console.log(`Running ${context.label} hook for ${context.projectName}: ${normalized.run}`);
359
+ await execaCommand(normalized.run, {
360
+ shell: true,
361
+ stdio: "inherit",
362
+ timeout: parseTimeout(normalized.timeout),
363
+ env: {
364
+ WAY_PROJECT: context.projectName,
365
+ WAY_REMOTE: context.remote,
366
+ WAY_DIR: context.wayDir,
367
+ WAY_DRY_RUN: context.dryRun ? "1" : "0"
368
+ }
369
+ });
370
+ }
371
+ }
157
372
  async function notifyUptimeKuma(result, pushUrl) {
158
373
  const status = result.failed.length > 0 ? "down" : "up";
159
374
  const msg = `Succeeded: ${result.succeeded.length}, Failed: ${result.failed.length}`;
@@ -193,6 +408,7 @@ async function restore(options) {
193
408
  const args = buildRestoreArgs(projectName, project, {
194
409
  target: options.target,
195
410
  snapshot: options.snapshot,
411
+ host: options.host,
196
412
  dryRun: options.dryRun,
197
413
  delete: options.delete,
198
414
  verbose: options.verbose
@@ -238,12 +454,22 @@ async function gc(options) {
238
454
 
239
455
  // src/commands/systemd.ts
240
456
  import { execSync } from "child_process";
241
- import fs3 from "fs";
242
- import path3 from "path";
457
+ import fs5 from "fs";
458
+ import path5 from "path";
459
+ function resolveWayCommandPath(options = {}) {
460
+ const env = options.env ?? process.env;
461
+ const argv = options.argv ?? process.argv;
462
+ const execPath = options.execPath ?? process.execPath;
463
+ const whichWay = options.whichWay ?? (() => execSync("which way", { encoding: "utf-8" }).trim());
464
+ 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];
467
+ return whichWay();
468
+ }
243
469
  async function systemd(options) {
244
470
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
245
471
  const config = loadConfig(wayDir, options.remote);
246
- const wayPath = execSync("which way", { encoding: "utf-8" }).trim();
472
+ const wayPath = resolveWayCommandPath();
247
473
  const currentUser = execSync("whoami", { encoding: "utf-8" }).trim();
248
474
  const serviceContent = `[Unit]
249
475
  Description=Way Backup Daemon
@@ -267,9 +493,9 @@ WantedBy=multi-user.target
267
493
  return;
268
494
  }
269
495
  const systemdDir = "/etc/systemd/system";
270
- const servicePath = path3.join(systemdDir, "way-backup.service");
496
+ const servicePath = path5.join(systemdDir, "way-backup.service");
271
497
  if (options.action === "install") {
272
- fs3.writeFileSync(servicePath, serviceContent);
498
+ fs5.writeFileSync(servicePath, serviceContent);
273
499
  execSync("systemctl daemon-reload");
274
500
  execSync("systemctl enable way-backup.service");
275
501
  execSync("systemctl start way-backup.service");
@@ -285,7 +511,7 @@ WantedBy=multi-user.target
285
511
  execSync("systemctl disable way-backup.service", { stdio: "ignore" });
286
512
  } catch {
287
513
  }
288
- if (fs3.existsSync(servicePath)) fs3.unlinkSync(servicePath);
514
+ if (fs5.existsSync(servicePath)) fs5.unlinkSync(servicePath);
289
515
  execSync("systemctl daemon-reload");
290
516
  console.log("Systemd service uninstalled");
291
517
  }
@@ -298,6 +524,23 @@ WantedBy=multi-user.target
298
524
  import cron from "node-cron";
299
525
  var isRunning = false;
300
526
  var taskQueue = [];
527
+ function resolveProjectSchedule(project, defaults = {}) {
528
+ if (project.schedule !== void 0) return project.schedule;
529
+ return defaults?.schedule ?? false;
530
+ }
531
+ function isEnabledSchedule(schedule) {
532
+ return typeof schedule === "string";
533
+ }
534
+ function assertValidSchedule(schedule, label) {
535
+ if (schedule === "") {
536
+ throw new Error(`${label} must be a cron expression or false. Use false to disable scheduling.`);
537
+ }
538
+ }
539
+ function addScheduledBackup(scheduledBackups, schedule, projectName) {
540
+ const projects = scheduledBackups.get(schedule) || [];
541
+ projects.push(projectName);
542
+ scheduledBackups.set(schedule, projects);
543
+ }
301
544
  async function executeTask(task) {
302
545
  taskQueue.push(task);
303
546
  if (isRunning) return;
@@ -316,18 +559,28 @@ async function daemon(options) {
316
559
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
317
560
  const config = loadConfig(wayDir, options.remote);
318
561
  console.log("Way daemon started");
562
+ const scheduledBackups = /* @__PURE__ */ new Map();
319
563
  for (const [name, project] of Object.entries(config.rules.projects)) {
320
- const schedule = project.schedule || config.rules.defaults?.schedule || "0 */2 * * *";
564
+ const schedule = resolveProjectSchedule(project, config.rules.defaults);
565
+ assertValidSchedule(schedule, `projects.${name}.schedule`);
566
+ if (!isEnabledSchedule(schedule)) {
567
+ console.log(`Skipped scheduled backup for ${name}`);
568
+ continue;
569
+ }
570
+ addScheduledBackup(scheduledBackups, schedule, name);
571
+ }
572
+ for (const [schedule, projects] of scheduledBackups.entries()) {
321
573
  cron.schedule(schedule, () => {
322
574
  executeTask(async () => {
323
- console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running backup: ${name}`);
324
- await backup({ remote: options.remote, projects: [name] });
575
+ console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running backup: ${projects.join(", ")}`);
576
+ await backup({ remote: options.remote, projects });
325
577
  });
326
578
  });
327
- console.log(`Scheduled backup for ${name}: ${schedule}`);
579
+ console.log(`Scheduled backup for ${projects.join(", ")}: ${schedule}`);
328
580
  }
329
581
  const pruneSchedule = config.rules.maintenance?.prune?.schedule;
330
- if (pruneSchedule) {
582
+ assertValidSchedule(pruneSchedule, "maintenance.prune.schedule");
583
+ if (isEnabledSchedule(pruneSchedule)) {
331
584
  cron.schedule(pruneSchedule, () => {
332
585
  executeTask(async () => {
333
586
  console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running prune`);
@@ -337,7 +590,8 @@ async function daemon(options) {
337
590
  console.log(`Scheduled prune: ${pruneSchedule}`);
338
591
  }
339
592
  const checkSchedule = config.rules.maintenance?.check?.schedule;
340
- if (checkSchedule) {
593
+ assertValidSchedule(checkSchedule, "maintenance.check.schedule");
594
+ if (isEnabledSchedule(checkSchedule)) {
341
595
  cron.schedule(checkSchedule, () => {
342
596
  executeTask(async () => {
343
597
  console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running check`);
@@ -357,60 +611,98 @@ async function daemon(options) {
357
611
 
358
612
  // src/cli.ts
359
613
  var program = new Command();
360
- program.name("way").version("0.6.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", `
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", `
361
615
  \u793A\u4F8B:
362
616
  $ way backup \u6267\u884C\u6240\u6709\u9879\u76EE\u5907\u4EFD
363
617
  $ way backup data \u53EA\u5907\u4EFD data \u9879\u76EE
364
618
  $ way backup --dry-run \u6A21\u62DF\u5907\u4EFD\uFF08\u4E0D\u5B9E\u9645\u5199\u5165\uFF09
365
619
  $ way restore data --target /tmp/restore --dry-run
620
+ $ 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
621
+ $ way init \u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6
366
622
  $ way gc \u6E05\u7406\u65E7\u5FEB\u7167
367
623
  $ way systemd install \u5B89\u88C5\u5B9A\u65F6\u4EFB\u52A1
368
624
  $ way restic snapshots \u67E5\u770B\u5FEB\u7167\u5217\u8868
369
625
  $ way restic restore abc123 --target /tmp/restore
370
626
  $ way --remote=s3 restic snapshots \u4F7F\u7528 s3 \u4ED3\u5E93
371
627
 
628
+ \u73AF\u5883\u53D8\u91CF:
629
+ WAY_DIR=/path/to/config \u6307\u5B9A\u914D\u7F6E\u76EE\u5F55\uFF08\u9ED8\u8BA4: ~/.way\uFF09
630
+ WAY_RESTIC_BIN=/path/restic \u6307\u5B9A restic \u4E8C\u8FDB\u5236\uFF0C\u4F18\u5148\u4E8E\u5185\u7F6E restic \u548C PATH
631
+
372
632
  \u6587\u6863: https://github.com/shellus/way
373
633
  `);
374
634
  function collectBackupArgs(command) {
375
635
  return command.args.filter((a) => a.startsWith("-") && !["--dry-run"].includes(a));
376
636
  }
377
- 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) {
637
+ var commonHelpText = `
638
+ \u5168\u5C40\u7528\u6CD5:
639
+ way --remote=oss <command> ... \u6307\u5B9A\u4ED3\u5E93\uFF08\u5168\u5C40\u9009\u9879\u9700\u653E\u5728\u5B50\u547D\u4EE4\u524D\uFF09
640
+
641
+ \u73AF\u5883\u53D8\u91CF:
642
+ WAY_DIR=/path/to/config \u6307\u5B9A\u914D\u7F6E\u76EE\u5F55\uFF08\u9ED8\u8BA4: ~/.way\uFF09
643
+ WAY_RESTIC_BIN=/path/restic \u6307\u5B9A restic \u4E8C\u8FDB\u5236\uFF0C\u4F18\u5148\u4E8E\u5185\u7F6E restic \u548C PATH
644
+ `;
645
+ program.command("init").description("\u521D\u59CB\u5316 way \u914D\u7F6E\u6587\u4EF6").addHelpText("after", commonHelpText).action(() => {
646
+ const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
647
+ const files = ["repositories.yaml", "rules.yaml"];
648
+ fs6.mkdirSync(wayDir, { recursive: true });
649
+ for (const file of files) {
650
+ const target = path6.join(wayDir, file);
651
+ if (fs6.existsSync(target)) {
652
+ throw new Error(`${target} already exists, aborting to avoid overwriting existing config.`);
653
+ }
654
+ }
655
+ for (const file of files) {
656
+ const source = resolveExampleConfigPath(file);
657
+ const target = path6.join(wayDir, file);
658
+ fs6.copyFileSync(source, target);
659
+ console.log(`Created ${target}`);
660
+ }
661
+ });
662
+ 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) {
378
663
  const remote = this.parent.opts().remote;
379
664
  const dryRun = this.opts().dryRun;
380
665
  const extraArgs = collectBackupArgs(this);
381
- await backup({ remote, projects: projects.filter((p) => !p.startsWith("-")), extraArgs, dryRun });
666
+ const result = await backup({
667
+ remote,
668
+ projects: projects.filter((p) => !p.startsWith("-")),
669
+ extraArgs,
670
+ dryRun
671
+ });
672
+ if (result.failed.length > 0) process.exitCode = 1;
382
673
  });
383
- 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("--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) {
674
+ 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) {
384
675
  const remote = this.parent.opts().remote;
385
676
  await restore({
386
677
  remote,
387
678
  projects,
388
679
  target: cmdOptions.target,
389
680
  snapshot: cmdOptions.snapshot,
681
+ host: cmdOptions.host,
390
682
  dryRun: cmdOptions.dryRun,
391
683
  delete: cmdOptions.delete,
392
684
  verbose: cmdOptions.verbose
393
685
  });
394
686
  });
395
- 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) {
687
+ 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) {
396
688
  const remote = this.parent.opts().remote;
397
689
  await gc({ remote, dryRun: cmdOptions.dryRun });
398
690
  });
399
- program.command("systemd <action>").description("\u7BA1\u7406 systemd \u5B9A\u65F6\u4EFB\u52A1 (show|install|uninstall|status)").action(async (action, options, command) => {
691
+ program.command("systemd <action>").description("\u7BA1\u7406 systemd \u5B9A\u65F6\u4EFB\u52A1 (show|install|uninstall|status)").addHelpText("after", commonHelpText).action(async (action, options, command) => {
400
692
  const remote = command.parent.opts().remote;
401
693
  await systemd({ remote, action });
402
694
  });
403
- program.command("daemon").description("\u542F\u52A8\u5E38\u9A7B\u8FDB\u7A0B\uFF0C\u6309\u914D\u7F6E\u5B9A\u65F6\u6267\u884C\u5907\u4EFD").action(async (options, command) => {
695
+ 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) => {
404
696
  const remote = command.parent.opts().remote;
405
697
  await daemon({ remote });
406
698
  });
407
- program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").action(() => {
699
+ program.command("env").description("\u663E\u793A\u73AF\u5883\u53D8\u91CF").addHelpText("after", commonHelpText).action(() => {
408
700
  const env = Object.entries(process.env).sort(([a], [b]) => a.localeCompare(b));
409
701
  for (const [key, value] of env) {
410
702
  console.log(`${key}=${value}`);
411
703
  }
412
704
  });
413
- program.command("restic [args...]").description("\u663E\u5F0F\u900F\u4F20\u7ED9 restic").allowUnknownOption().allowExcessArguments().action(async function(args) {
705
+ program.command("restic [args...]").description("\u663E\u5F0F\u900F\u4F20\u7ED9 restic").addHelpText("after", commonHelpText).allowUnknownOption().allowExcessArguments().action(async function(args) {
414
706
  const remote = this.parent.opts().remote;
415
707
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
416
708
  const config = loadConfig(wayDir, remote);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shellus/way",
3
- "version": "0.6.0",
3
+ "version": "0.6.9",
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 # 不执行