@shellus/way 0.6.1 → 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. 配置定时备份
@@ -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
 
@@ -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,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,147 @@ 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
+ 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
+ }
158
372
  async function notifyUptimeKuma(result, pushUrl) {
159
373
  const status = result.failed.length > 0 ? "down" : "up";
160
374
  const msg = `Succeeded: ${result.succeeded.length}, Failed: ${result.failed.length}`;
@@ -240,12 +454,22 @@ async function gc(options) {
240
454
 
241
455
  // src/commands/systemd.ts
242
456
  import { execSync } from "child_process";
243
- import fs3 from "fs";
244
- 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
+ }
245
469
  async function systemd(options) {
246
470
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
247
471
  const config = loadConfig(wayDir, options.remote);
248
- const wayPath = execSync("which way", { encoding: "utf-8" }).trim();
472
+ const wayPath = resolveWayCommandPath();
249
473
  const currentUser = execSync("whoami", { encoding: "utf-8" }).trim();
250
474
  const serviceContent = `[Unit]
251
475
  Description=Way Backup Daemon
@@ -269,9 +493,9 @@ WantedBy=multi-user.target
269
493
  return;
270
494
  }
271
495
  const systemdDir = "/etc/systemd/system";
272
- const servicePath = path3.join(systemdDir, "way-backup.service");
496
+ const servicePath = path5.join(systemdDir, "way-backup.service");
273
497
  if (options.action === "install") {
274
- fs3.writeFileSync(servicePath, serviceContent);
498
+ fs5.writeFileSync(servicePath, serviceContent);
275
499
  execSync("systemctl daemon-reload");
276
500
  execSync("systemctl enable way-backup.service");
277
501
  execSync("systemctl start way-backup.service");
@@ -287,7 +511,7 @@ WantedBy=multi-user.target
287
511
  execSync("systemctl disable way-backup.service", { stdio: "ignore" });
288
512
  } catch {
289
513
  }
290
- if (fs3.existsSync(servicePath)) fs3.unlinkSync(servicePath);
514
+ if (fs5.existsSync(servicePath)) fs5.unlinkSync(servicePath);
291
515
  execSync("systemctl daemon-reload");
292
516
  console.log("Systemd service uninstalled");
293
517
  }
@@ -300,6 +524,23 @@ WantedBy=multi-user.target
300
524
  import cron from "node-cron";
301
525
  var isRunning = false;
302
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
+ }
303
544
  async function executeTask(task) {
304
545
  taskQueue.push(task);
305
546
  if (isRunning) return;
@@ -318,18 +559,28 @@ async function daemon(options) {
318
559
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
319
560
  const config = loadConfig(wayDir, options.remote);
320
561
  console.log("Way daemon started");
562
+ const scheduledBackups = /* @__PURE__ */ new Map();
321
563
  for (const [name, project] of Object.entries(config.rules.projects)) {
322
- 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()) {
323
573
  cron.schedule(schedule, () => {
324
574
  executeTask(async () => {
325
- console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running backup: ${name}`);
326
- 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 });
327
577
  });
328
578
  });
329
- console.log(`Scheduled backup for ${name}: ${schedule}`);
579
+ console.log(`Scheduled backup for ${projects.join(", ")}: ${schedule}`);
330
580
  }
331
581
  const pruneSchedule = config.rules.maintenance?.prune?.schedule;
332
- if (pruneSchedule) {
582
+ assertValidSchedule(pruneSchedule, "maintenance.prune.schedule");
583
+ if (isEnabledSchedule(pruneSchedule)) {
333
584
  cron.schedule(pruneSchedule, () => {
334
585
  executeTask(async () => {
335
586
  console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running prune`);
@@ -339,7 +590,8 @@ async function daemon(options) {
339
590
  console.log(`Scheduled prune: ${pruneSchedule}`);
340
591
  }
341
592
  const checkSchedule = config.rules.maintenance?.check?.schedule;
342
- if (checkSchedule) {
593
+ assertValidSchedule(checkSchedule, "maintenance.check.schedule");
594
+ if (isEnabledSchedule(checkSchedule)) {
343
595
  cron.schedule(checkSchedule, () => {
344
596
  executeTask(async () => {
345
597
  console.log(`[${(/* @__PURE__ */ new Date()).toISOString()}] Running check`);
@@ -359,30 +611,67 @@ async function daemon(options) {
359
611
 
360
612
  // src/cli.ts
361
613
  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", `
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", `
363
615
  \u793A\u4F8B:
364
616
  $ way backup \u6267\u884C\u6240\u6709\u9879\u76EE\u5907\u4EFD
365
617
  $ way backup data \u53EA\u5907\u4EFD data \u9879\u76EE
366
618
  $ way backup --dry-run \u6A21\u62DF\u5907\u4EFD\uFF08\u4E0D\u5B9E\u9645\u5199\u5165\uFF09
367
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
368
622
  $ way gc \u6E05\u7406\u65E7\u5FEB\u7167
369
623
  $ way systemd install \u5B89\u88C5\u5B9A\u65F6\u4EFB\u52A1
370
624
  $ way restic snapshots \u67E5\u770B\u5FEB\u7167\u5217\u8868
371
625
  $ way restic restore abc123 --target /tmp/restore
372
626
  $ way --remote=s3 restic snapshots \u4F7F\u7528 s3 \u4ED3\u5E93
373
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
+
374
632
  \u6587\u6863: https://github.com/shellus/way
375
633
  `);
376
634
  function collectBackupArgs(command) {
377
635
  return command.args.filter((a) => a.startsWith("-") && !["--dry-run"].includes(a));
378
636
  }
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) {
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) {
380
663
  const remote = this.parent.opts().remote;
381
664
  const dryRun = this.opts().dryRun;
382
665
  const extraArgs = collectBackupArgs(this);
383
- 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;
384
673
  });
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) {
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) {
386
675
  const remote = this.parent.opts().remote;
387
676
  await restore({
388
677
  remote,
@@ -395,25 +684,25 @@ program.command("restore [projects...]").description("\u6309 rules.yaml \u6062\u
395
684
  verbose: cmdOptions.verbose
396
685
  });
397
686
  });
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) {
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) {
399
688
  const remote = this.parent.opts().remote;
400
689
  await gc({ remote, dryRun: cmdOptions.dryRun });
401
690
  });
402
- 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) => {
403
692
  const remote = command.parent.opts().remote;
404
693
  await systemd({ remote, action });
405
694
  });
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) => {
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) => {
407
696
  const remote = command.parent.opts().remote;
408
697
  await daemon({ remote });
409
698
  });
410
- 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(() => {
411
700
  const env = Object.entries(process.env).sort(([a], [b]) => a.localeCompare(b));
412
701
  for (const [key, value] of env) {
413
702
  console.log(`${key}=${value}`);
414
703
  }
415
704
  });
416
- 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) {
417
706
  const remote = this.parent.opts().remote;
418
707
  const wayDir = process.env.WAY_DIR || `${process.env.HOME}/.way`;
419
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.1",
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 # 不执行