@peiyanlu/cli-utils 0.0.4 → 0.0.6

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/dist/index.mjs ADDED
@@ -0,0 +1,1115 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
3
+ import { EOL } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { exec, execSync, spawn, spawnSync } from "node:child_process";
6
+ import { styleText } from "node:util";
7
+
8
+ //#region src/enums.ts
9
+ let PkgManager = /* @__PURE__ */ function(PkgManager$1) {
10
+ PkgManager$1["NPM"] = "npm";
11
+ PkgManager$1["YARN"] = "yarn";
12
+ PkgManager$1["PNPM"] = "pnpm";
13
+ return PkgManager$1;
14
+ }({});
15
+ /**
16
+ * @deprecated Use `ConfirmResult` instead.
17
+ */
18
+ let YesOrNo = /* @__PURE__ */ function(YesOrNo$1) {
19
+ YesOrNo$1["Yes"] = "yes";
20
+ YesOrNo$1["No"] = "no";
21
+ YesOrNo$1["Ignore"] = "ignore";
22
+ return YesOrNo$1;
23
+ }({});
24
+ let ConfirmResult = /* @__PURE__ */ function(ConfirmResult$1) {
25
+ ConfirmResult$1["YES"] = "yes";
26
+ ConfirmResult$1["NO"] = "no";
27
+ ConfirmResult$1["IGNORE"] = "ignore";
28
+ return ConfirmResult$1;
29
+ }({});
30
+ let HttpLibrary = /* @__PURE__ */ function(HttpLibrary$1) {
31
+ HttpLibrary$1["EXPRESS"] = "express";
32
+ HttpLibrary$1["FASTIFY"] = "fastify";
33
+ HttpLibrary$1["KOA"] = "koa";
34
+ HttpLibrary$1["HONO"] = "hono";
35
+ return HttpLibrary$1;
36
+ }({});
37
+
38
+ //#endregion
39
+ //#region src/styleText.ts
40
+ const dim = (text) => styleText(["dim"], text);
41
+ const red = (text) => styleText(["red"], text);
42
+
43
+ //#endregion
44
+ //#region src/shell.ts
45
+ /** 异步执行 `spawn` 获取字符串类型的结果 */
46
+ const spawnAsync = (cmd, args, options) => {
47
+ return new Promise((resolve$1, reject) => {
48
+ const { trim, error, dryRun, ...others } = options ?? {};
49
+ const fullCmd = stringifyArgs([cmd, ...args]);
50
+ if (dryRun) {
51
+ console.log(`${dim("[dry-run]")} ${fullCmd}`);
52
+ return resolve$1(void 0);
53
+ }
54
+ const child = spawn(cmd, args, { ...others });
55
+ let stdout = "";
56
+ child.stdout?.setEncoding("utf-8");
57
+ child.stdout?.on("data", (d) => {
58
+ stdout += trim ? d.trim() : d;
59
+ });
60
+ let stderr = "";
61
+ child.stderr?.setEncoding("utf-8");
62
+ child.stderr?.on("data", (d) => {
63
+ stderr += trim ? d.trim() : d;
64
+ });
65
+ child.on("error", reject);
66
+ child.on("close", (code) => {
67
+ if (code !== 0) {
68
+ const msg = `${red("spawnAsync")} ${dim(fullCmd)} ${stderr}`;
69
+ if (error === "log") {
70
+ console.error(msg);
71
+ return resolve$1(void 0);
72
+ }
73
+ if (error === "throw") return reject(new Error(msg));
74
+ return resolve$1(void 0);
75
+ }
76
+ resolve$1(stdout);
77
+ });
78
+ });
79
+ };
80
+ /** 异步执行 `exec` 获取字符串类型的结果 */
81
+ const execAsync = (cmd, argsOrOptions, maybeOptions) => {
82
+ return new Promise((resolve$1, reject) => {
83
+ let command;
84
+ let options;
85
+ if (Array.isArray(argsOrOptions)) {
86
+ command = stringifyArgs([cmd, ...argsOrOptions]);
87
+ options = maybeOptions;
88
+ } else {
89
+ command = cmd;
90
+ options = argsOrOptions;
91
+ }
92
+ const { trim, dryRun, error, ...others } = options ?? {};
93
+ if (dryRun) {
94
+ console.log(`${dim("[dry-run]")} ${command}`);
95
+ return resolve$1(void 0);
96
+ }
97
+ exec(command, { ...others }, (err, stdout, stderr) => {
98
+ if (err) {
99
+ const msg = `${red("execAsync")} ${dim(command)} ${stderr || err.message}`;
100
+ if (error === "log") {
101
+ console.error(msg);
102
+ return resolve$1(void 0);
103
+ }
104
+ if (error === "throw") return reject(new Error(msg));
105
+ return resolve$1(void 0);
106
+ }
107
+ resolve$1(trim ? stdout.trim() : stdout);
108
+ });
109
+ });
110
+ };
111
+ /** 执行 `spawnSync` 获取字符串类型的结果 */
112
+ const spawnSyncWithString = (cmd, args, options) => {
113
+ const { trim, error, dryRun, ...others } = options ?? {};
114
+ const fullCmd = stringifyArgs([cmd, ...args]);
115
+ if (dryRun) {
116
+ console.log(`${dim("[dry-run]")} ${fullCmd}`);
117
+ return;
118
+ }
119
+ const { stdout, stderr, status, error: err } = spawnSync(cmd, args, {
120
+ encoding: "utf-8",
121
+ ...others
122
+ });
123
+ if (status !== 0 || err) {
124
+ const detail = err?.message || stderr?.toString?.() || "";
125
+ const msg = `${red("spawnSync")} ${dim(fullCmd)} ${detail}`;
126
+ if (error === "log") {
127
+ console.error(msg);
128
+ return;
129
+ }
130
+ if (error === "throw") throw new Error(msg);
131
+ return;
132
+ }
133
+ return trim ? stdout.trim() : stdout;
134
+ };
135
+ /** 执行 `execSync` 获取字符串类型的结果 */
136
+ const execSyncWithString = (cmd, argsOrOptions, maybeOptions) => {
137
+ let command;
138
+ let options;
139
+ if (Array.isArray(argsOrOptions)) {
140
+ command = stringifyArgs([cmd, ...argsOrOptions]);
141
+ options = maybeOptions;
142
+ } else {
143
+ command = cmd;
144
+ options = argsOrOptions;
145
+ }
146
+ const { trim, dryRun, error, ...others } = options ?? {};
147
+ if (dryRun) {
148
+ console.log(`${dim("[dry-run]")} ${command}`);
149
+ return;
150
+ }
151
+ try {
152
+ const stdout = execSync(command, {
153
+ encoding: "utf-8",
154
+ ...others
155
+ });
156
+ return trim ? stdout.trim() : stdout;
157
+ } catch (e) {
158
+ const stderr = e?.stderr?.toString?.() || e?.message || "";
159
+ const msg = `${red("execSync")} ${dim(command)} ${stderr}`;
160
+ if (error === "log") {
161
+ console.error(msg);
162
+ return;
163
+ }
164
+ if (error === "throw") throw new Error(msg);
165
+ return;
166
+ }
167
+ };
168
+ /** 基于 {@link spawnAsync} 实现 */
169
+ const runGit = async (args, options = { trim: true }) => {
170
+ return spawnAsync("git", args, options);
171
+ };
172
+ /** 基于 {@link spawnSyncWithString} 实现 */
173
+ const runGitSync = (args, options) => {
174
+ return spawnSyncWithString("git", args, { ...options });
175
+ };
176
+ /** 基于 {@link execAsync} 实现 */
177
+ const runNpm = (args, options = { trim: true }) => {
178
+ return execAsync("npm", args, options);
179
+ };
180
+ /** 基于 {@link execSyncWithString} 实现 */
181
+ const runNpmSync = (args, options) => {
182
+ return execSyncWithString("npm", args, { ...options });
183
+ };
184
+ /** 基于 {@link spawnAsync} 实现 */
185
+ const runNode = (args, options) => {
186
+ return spawnAsync("node", args, options);
187
+ };
188
+ /** 基于 {@link spawnSyncWithString} 实现 */
189
+ const runNodeSync = (args, options) => {
190
+ return spawnSyncWithString("node", args, options);
191
+ };
192
+ /** 支持所有支持 `--version` 命令的脚本查看版本 */
193
+ const checkVersion = async (cmd) => {
194
+ return execAsync(`${cmd} --version`);
195
+ };
196
+
197
+ //#endregion
198
+ //#region src/utils.ts
199
+ const isValidPackageName = (packageName) => {
200
+ return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test(packageName);
201
+ };
202
+ const toValidPackageName = (packageName) => packageName.trim().toLowerCase().replace(/\s+/g, "-").replace(/^[._]/, "").replace(/[^a-z\d\-~]+/g, "-");
203
+ const toValidProjectName = (projectName) => projectName.trim().replace(/\/+$/g, "");
204
+ const emptyDir = async (dir, ignore = []) => {
205
+ if (!existsSync(dir)) return false;
206
+ for (const file of await readdir(dir)) {
207
+ if (ignore.includes(file)) continue;
208
+ await rm(resolve(dir, file), {
209
+ recursive: true,
210
+ force: true
211
+ });
212
+ }
213
+ return true;
214
+ };
215
+ const isEmpty = async (path, ignore = []) => {
216
+ return (await readdir(path)).filter((f) => !ignore.includes(f)).length === 0;
217
+ };
218
+ const editFile = async (file, callback) => {
219
+ if (!existsSync(file)) return;
220
+ return writeFile(file, callback(await readFile(file, "utf-8")), "utf-8");
221
+ };
222
+ const editJsonFile = (file, callback) => {
223
+ return editFile(file, (str) => {
224
+ try {
225
+ const json = JSON.parse(str);
226
+ callback(json);
227
+ return JSON.stringify(json, null, 2);
228
+ } catch (e) {
229
+ console.error(e);
230
+ return str;
231
+ }
232
+ });
233
+ };
234
+ const readSubDirs = async (source, ignore = []) => {
235
+ return (await readdir(source, { withFileTypes: true })).filter((k) => k.isDirectory() && !ignore.includes(k.name)).map((dir) => dir.name);
236
+ };
237
+ const copyDirAsync = async (src, dest, options) => {
238
+ await mkdir(dest, { recursive: true });
239
+ const entries = await readdir(src, { withFileTypes: true });
240
+ for (const entry of entries) {
241
+ const name = entry.name;
242
+ const isDir = entry.isDirectory();
243
+ const { rename = {}, skips = [] } = options;
244
+ const relName = rename[name] ?? name;
245
+ if (skips.some((rule) => rule(name, isDir))) continue;
246
+ const from = join(src, name);
247
+ const to = join(dest, relName);
248
+ if (isDir) await copyDirAsync(from, to, options);
249
+ else await copyFile(from, to);
250
+ }
251
+ };
252
+ const readJsonFile = (file) => {
253
+ if (!existsSync(file)) return {};
254
+ try {
255
+ return JSON.parse(readFileSync(file, "utf-8"));
256
+ } catch (e) {
257
+ return {};
258
+ }
259
+ };
260
+ const getPackageInfo = (pkgName, getPkgDir) => {
261
+ const pkgDir = resolve(getPkgDir(pkgName));
262
+ const pkgPath = resolve(pkgDir, "package.json");
263
+ return {
264
+ pkg: readJsonFile(pkgPath),
265
+ pkgDir,
266
+ pkgPath
267
+ };
268
+ };
269
+ /** 通过包管理器执行脚本时生效 UserAgent: `process.env.npm_config_user_agent` */
270
+ const pkgFromUserAgent = (userAgent) => {
271
+ if (!userAgent) return void 0;
272
+ const [name, version] = userAgent.split(" ")[0].split("/");
273
+ return {
274
+ name,
275
+ version
276
+ };
277
+ };
278
+ /** 同步执行 Node CLI(用于测试环境) */
279
+ const runCliForTest = (path, args = [], options) => {
280
+ return runNodeSync([path, ...args], {
281
+ env: {
282
+ ...process.env,
283
+ _VITE_TEST_CLI: "true"
284
+ },
285
+ ...options
286
+ });
287
+ };
288
+ /** 判断测试文件(夹) */
289
+ const isTestFile = (name) => {
290
+ return [
291
+ /(^|[\\/])(test(s?)|__test(s?)__)([\\/]|$)/,
292
+ /\.([a-zA-Z0-9]+-)?(test|spec)\.m?(ts|js)$/,
293
+ /^vitest([-.])(.*)\.m?(ts|js)$/
294
+ ].some((reg) => reg.test(name));
295
+ };
296
+ /** 解析 Github 链接获取 owner 和 repo */
297
+ const parseGitHubRepo = (url) => {
298
+ const match = url.trim().match(/github(?:\.com)?[:/](.+?)\/(.+?)(?:[#/?].+?)?(?:\.git)?$/);
299
+ return match ? match.slice(1, 3) : [];
300
+ };
301
+ /** 基于 EOL 的可多换行函数 */
302
+ const eol = (n = 1) => EOL.repeat(n);
303
+ /** 多空格函数 */
304
+ const space = (n = 1) => " ".repeat(n);
305
+ /** 将字符串以空格分割为数组 */
306
+ const parseArgs = (args) => args.trim() ? args.trim().split(space()) : [];
307
+ /** 将数组以空格拼接为字符串 */
308
+ const stringifyArgs = (args) => args.length ? args.join(space()) : "";
309
+ /** 去掉模板字符串首尾换行 */
310
+ const trimTemplate = (str) => str.replace(/^\s*\n+|\n+\s*$/g, "");
311
+ /** 生成 GitHub 仓库主页地址 */
312
+ const getGithubUrl = (owner, repo) => {
313
+ return `https://github.com/${owner}/${repo}`;
314
+ };
315
+ /** 生成 GitHub Release 页面地址 */
316
+ const getGithubReleaseUrl = (owner, repo, tag) => {
317
+ return `${getGithubUrl(owner, repo)}/releases/tag/${encodeURIComponent(tag)}`;
318
+ };
319
+ /** 生成 npm 包指定版本的详情页地址 */
320
+ const getPackageUrl = (pkg, version) => {
321
+ return `https://www.npmjs.com/package/${pkg}/v/${version}`;
322
+ };
323
+
324
+ //#endregion
325
+ //#region src/joinUrl.ts
326
+ function joinUrl(input) {
327
+ const temps = Array.isArray(input) ? input : [...arguments];
328
+ if (temps.length === 0) return "";
329
+ const result = [];
330
+ const parts = [...temps];
331
+ /** 协议正则 */
332
+ const PROTOCOL_RE = /^[^/:]+:\/*$/;
333
+ const FILE_PROTOCOL_RE = /^file:\/\/\//;
334
+ if (PROTOCOL_RE.test(parts[0]) && parts.length > 1) {
335
+ parts[1] = parts[0] + parts[1];
336
+ parts.shift();
337
+ }
338
+ if (FILE_PROTOCOL_RE.test(parts[0])) parts[0] = parts[0].replace(/^([^/:]+):\/*/, "$1:///");
339
+ else parts[0] = parts[0].replace(/^([^/:]+):\/*/, "$1://");
340
+ parts.forEach((part, index) => {
341
+ if (!part) return;
342
+ let segment = part;
343
+ if (index > 0) segment = segment.replace(/^\/+/, "");
344
+ if (index < parts.length - 1) segment = segment.replace(/\/+$/, "");
345
+ else segment = segment.replace(/\/+$/, "/");
346
+ result.push(segment);
347
+ });
348
+ let url = result.join("/");
349
+ url = url.replace(/\/(\?|&|#[^!])/g, "$1");
350
+ const [base, ...queryParts] = url.split("?");
351
+ url = base + (queryParts.length ? "?" + queryParts.join("&") : "");
352
+ return url;
353
+ }
354
+
355
+ //#endregion
356
+ //#region src/git.ts
357
+ /** 判断指定目录是否是 git 仓库 */
358
+ const isGitRepo = async (dir = ".") => {
359
+ const target = resolve(process.cwd(), dir);
360
+ return "true" === await runGit([
361
+ "-C",
362
+ target,
363
+ "rev-parse",
364
+ "--is-inside-work-tree"
365
+ ]).catch((err) => {
366
+ if (err?.message?.includes("safe.directory")) console.warn(`⚠️ Git safe.directory restrictions: Please run:\ngit config --global --add safe.directory ${target}`);
367
+ return false;
368
+ });
369
+ };
370
+ /** 获取指定的 git 配置 */
371
+ const getGitConfig = (key, global = true) => {
372
+ return runGit([
373
+ "config",
374
+ ...global ? ["--global"] : [],
375
+ key
376
+ ]);
377
+ };
378
+ /** 获取 git 远程地址 */
379
+ const getGitRemoteUrl = async (remoteName = "origin") => {
380
+ return runGit([
381
+ "remote",
382
+ "get-url",
383
+ remoteName
384
+ ]).catch((_) => runGit([
385
+ "config",
386
+ "--get",
387
+ `remote.${remoteName}.url`
388
+ ]));
389
+ };
390
+ /** 获取当前分支 */
391
+ const getCurrentBranch = async () => {
392
+ let branch = await runGit(["branch", "--show-current"]);
393
+ if (!branch) {
394
+ branch = await runGit([
395
+ "rev-parse",
396
+ "--abbrev-ref",
397
+ "HEAD"
398
+ ]);
399
+ if (branch === "HEAD") return void 0;
400
+ }
401
+ return branch;
402
+ };
403
+ /** 获取分支关联的远程地址 */
404
+ const getRemoteForBranch = (branch) => {
405
+ return runGit([
406
+ "config",
407
+ "--get",
408
+ `branch.${branch}.remote`
409
+ ]);
410
+ };
411
+ /** 获取关联的所有远程地址 */
412
+ const getAllRemotes = async () => {
413
+ return (await runGit(["remote"]))?.split("\n").filter(Boolean) ?? [];
414
+ };
415
+ /** 获取默认的远程地址 */
416
+ const getDefaultRemote = async (branch) => {
417
+ const targetBranch = branch || await getCurrentBranch();
418
+ return targetBranch ? await getRemoteForBranch(targetBranch) : void 0;
419
+ };
420
+ /** 获取默认远程地址之外的远程地址 */
421
+ const getOtherRemotes = async (branch) => {
422
+ const defaultRemote = await getDefaultRemote(branch);
423
+ return (await getAllRemotes()).filter((r) => r !== defaultRemote);
424
+ };
425
+ /** 提取所有分支 */
426
+ const fetchAllBranch = (remoteName = "origin") => {
427
+ return runGit([
428
+ "fetch",
429
+ remoteName,
430
+ "--recurse-submodules=no",
431
+ "--prune"
432
+ ]);
433
+ };
434
+ /**
435
+ * 获取本地所有 tag
436
+ * @returns {Promise<string[]>}
437
+ * @defaults git tag --list
438
+ */
439
+ const getLocalTags = async () => {
440
+ const tags = await runGit(["tag", "--list"]);
441
+ return tags ? tags.split("\n").filter(Boolean) : [];
442
+ };
443
+ /** 获取远程 tags */
444
+ const getSortedTags = async (match = "*", exclude = "*-beta.*", sort = "v:refname", count = 0) => {
445
+ const res = await runGit([
446
+ "for-each-ref",
447
+ "--format=%(refname:short)",
448
+ `--sort=-${sort}`,
449
+ `--exclude=${exclude}`,
450
+ `--count=${count}`,
451
+ `refs/tags/${match}`
452
+ ]);
453
+ return res ? res.split("\n").filter(Boolean) : [];
454
+ };
455
+ /**
456
+ * 获取远程(或所有 refs)中的 tag
457
+ * 使用 for-each-ref 以支持版本号排序
458
+ * @param {string} match 默认 *
459
+ * @param {string} exclude 默认 beta
460
+ * @returns {Promise<string[]>}
461
+ * @defaults git for-each-ref --sort=-v:refname --format=%(refname:short) refs/tags/<match>
462
+ */
463
+ const getRemoteTags = async (match = "*", exclude = "*-beta.*") => {
464
+ return getSortedTags(match, exclude);
465
+ };
466
+ /**
467
+ * 获取当前工作区状态(不包含未跟踪文件)
468
+ * @returns {Promise<string | undefined>}
469
+ * @defaults git status --short --untracked-files=no
470
+ */
471
+ const getStatus = () => {
472
+ return runGit([
473
+ "status",
474
+ "--short",
475
+ "--untracked-files=no"
476
+ ], { trim: false });
477
+ };
478
+ /**
479
+ * 为 git status / changeset 输出添加颜色
480
+ *
481
+ * M -> 黄色(修改)
482
+ * A -> 绿色(新增)
483
+ * D -> 红色(删除)
484
+ * @param {string} log git status --short 输出
485
+ * @returns {string}
486
+ */
487
+ const coloredChangeset = (log) => {
488
+ const colorStatusChar = (ch) => {
489
+ switch (ch) {
490
+ case "M": return `\x1b[33m${ch}\x1b[0m\x1b[2m`;
491
+ case "A": return `\x1b[32m${ch}\x1b[0m\x1b[2m`;
492
+ case "D": return `\x1b[31m${ch}\x1b[0m\x1b[2m`;
493
+ default: return ch;
494
+ }
495
+ };
496
+ const colorStatus = (status) => status.split("").map(colorStatusChar).join("");
497
+ return log.split("\n").filter(Boolean).map((line) => {
498
+ const status = line.slice(0, 2);
499
+ const file = line.slice(3);
500
+ return `${colorStatus(status)} ${file}`;
501
+ }).join("\n");
502
+ };
503
+ /**
504
+ * 获取当前分支对应的远程信息
505
+ * @returns {Promise<{remoteName: string, remoteUrl: string | undefined}>}
506
+ */
507
+ const getRemote = async () => {
508
+ const branch = await getCurrentBranch();
509
+ const remoteName = branch && await getRemoteForBranch(branch) || "origin";
510
+ return {
511
+ remoteName,
512
+ remoteUrl: await runGit([
513
+ "remote",
514
+ "get-url",
515
+ remoteName
516
+ ]).catch((_) => runGit([
517
+ "config",
518
+ "--get",
519
+ `remote.${remoteName}.url`
520
+ ]))
521
+ };
522
+ };
523
+ /**
524
+ * 获取最新 tag
525
+ *
526
+ * 默认:
527
+ * - 匹配所有 tag
528
+ * - 排除 beta 版本
529
+ * @param {string} match 默认 *
530
+ * @param {string} exclude 默认 beta
531
+ * @returns {Promise<string | undefined>}
532
+ */
533
+ const getLatestTag = async (match = "*", exclude = "*-beta.*") => {
534
+ const [latestTag] = await getSortedTags(match, exclude, void 0, 1);
535
+ return latestTag;
536
+ };
537
+ /**
538
+ * 获取上一个 tag
539
+ *
540
+ * 默认:
541
+ * - 匹配所有 tag
542
+ * - 排除 beta 版本
543
+ * @param {string} latestTag
544
+ * @param {string} match 默认 *
545
+ * @param {string} exclude 默认 beta
546
+ * @returns {Promise<string | undefined>}
547
+ */
548
+ const getPreviousTag = async (latestTag, match = "*", exclude = "*-beta.*") => {
549
+ const all = await getSortedTags(match, exclude, void 0, 2);
550
+ return all[all.findIndex((k) => latestTag === k) + 1];
551
+ };
552
+ /**
553
+ * 计算 changelog 的 commit 范围
554
+ * @param {boolean} isIncrement 是否为版本递增发布
555
+ * @param {string} match tag match
556
+ * @param {string} exclude tag exclude
557
+ * @returns {Promise<{from: string, to: string}>}
558
+ */
559
+ const resolveChangelogRange = async (isIncrement = true, match = "*", exclude = "*-beta.*") => {
560
+ const latestTag = await getLatestTag(match, exclude);
561
+ if (!latestTag) return {
562
+ from: "",
563
+ to: "HEAD"
564
+ };
565
+ const previousTag = await getPreviousTag(latestTag);
566
+ if (!isIncrement && previousTag) return {
567
+ from: previousTag,
568
+ to: `${latestTag}^1`
569
+ };
570
+ return {
571
+ from: latestTag,
572
+ to: "HEAD"
573
+ };
574
+ };
575
+ /**
576
+ * 将短 hash 解析为完整 hash
577
+ * @param {string} short
578
+ * @returns {Promise<string | undefined>}
579
+ */
580
+ const getFullHash = (short) => {
581
+ return runGit(["rev-parse", short]);
582
+ };
583
+ /**
584
+ * 获取指定范围内的 commit 日志
585
+ * @param {string} from
586
+ * @param {string} to
587
+ * @param {string} scope 文件或目录范围
588
+ * @returns {Promise<string | undefined>}
589
+ */
590
+ const getLog = async (from = "", to = "HEAD", scope) => {
591
+ const cmd = ["log", `--pretty=format:* %s (%h)`];
592
+ if (from) cmd.push(`${from}...${to}`);
593
+ if (scope) cmd.push(...["--", scope]);
594
+ return runGit(cmd, { trim: false });
595
+ };
596
+ /**
597
+ * 判断工作区是否干净(无未提交修改)
598
+ * @returns {Promise<boolean>}
599
+ */
600
+ const isWorkingDirClean = async () => {
601
+ return (await runGit(["status", "-s"]))?.length === 0;
602
+ };
603
+ /** 判断字符串是否为合法 remote 名称 */
604
+ const isRemoteName = (remoteName) => {
605
+ return remoteName && !remoteName.includes("/");
606
+ };
607
+ /** 判断当前分支是否已设置 upstream */
608
+ const hasUpstreamBranch = async () => {
609
+ const branch = await runGit([
610
+ "for-each-ref",
611
+ "--format=%(upstream:short)",
612
+ await runGit(["symbolic-ref", "HEAD"])
613
+ ]);
614
+ return Boolean(branch);
615
+ };
616
+ /** 获取 push 所需的 upstream 参数 */
617
+ const getUpstreamArgs = async (remoteName, branch) => {
618
+ const hasUpstream = await hasUpstreamBranch();
619
+ const target = branch || await getCurrentBranch();
620
+ if (!hasUpstream) return [
621
+ "--set-upstream",
622
+ remoteName,
623
+ target
624
+ ];
625
+ return [remoteName, target];
626
+ };
627
+ /** 统计自最新 tag 以来的提交数量 */
628
+ const countCommitsSinceLatestTag = async () => {
629
+ const latestTag = await getLatestTag();
630
+ return runGit([
631
+ "rev-list",
632
+ latestTag ? `${latestTag}...HEAD` : "HEAD",
633
+ "--count"
634
+ ]).then(Number);
635
+ };
636
+ /** git add(包含未追踪文件) */
637
+ const gitAddAll = async (args = []) => {
638
+ await runGit([
639
+ "add",
640
+ "--all",
641
+ ...args
642
+ ]);
643
+ };
644
+ /** git add(仅已追踪文件) */
645
+ const gitAddTracked = async (args = []) => {
646
+ await runGit([
647
+ "add",
648
+ "--update",
649
+ ...args
650
+ ]);
651
+ };
652
+ /** git commit */
653
+ const gitCommit = async (message, args = []) => {
654
+ await runGit([
655
+ "commit",
656
+ "--message",
657
+ message,
658
+ ...args
659
+ ]);
660
+ };
661
+ /** git commit amend */
662
+ const gitCommitAmend = async (message, args = []) => {
663
+ await runGit([
664
+ "commit",
665
+ "--amend",
666
+ "--message",
667
+ message,
668
+ ...args
669
+ ]);
670
+ };
671
+ /** 创建 annotated tag */
672
+ const gitTagAnnotated = async (tag, message = tag, args = []) => {
673
+ await runGit([
674
+ "tag",
675
+ "--annotate",
676
+ "--message",
677
+ message,
678
+ tag,
679
+ ...args
680
+ ]);
681
+ };
682
+ /** 创建 lightweight tag */
683
+ const gitTagLightweight = async (tag, message = tag, args = []) => {
684
+ await runGit([
685
+ "tag",
686
+ "--message",
687
+ message,
688
+ tag,
689
+ ...args
690
+ ]);
691
+ };
692
+ /**
693
+ * 推送 tag 到远程
694
+ * @param {string} remoteName
695
+ * @param {string} tag
696
+ * @param {string[]} args
697
+ * @returns {Promise<string | undefined>}
698
+ * @defaults git push <remoteName> refs/tags/<tag>
699
+ */
700
+ const pushTag = async (remoteName, tag, args = []) => {
701
+ await runGit([
702
+ "push",
703
+ remoteName,
704
+ `refs/tags/${tag}`,
705
+ ...args
706
+ ]);
707
+ };
708
+ /**
709
+ * 推送分支到远程
710
+ * 自动处理 upstream 设置
711
+ * @param {string} remoteName
712
+ * @param {string} branch
713
+ * @param {string[]} args
714
+ * @returns {Promise<string | undefined>}
715
+ */
716
+ const pushBranch = async (remoteName, branch, args = []) => {
717
+ await runGit([
718
+ "push",
719
+ ...await getUpstreamArgs(remoteName, branch),
720
+ ...args
721
+ ]);
722
+ };
723
+ /**
724
+ * 撤销【工作区】中某个文件的修改(未暂存的改动)
725
+ * - 不影响暂存区
726
+ * - 不影响提交历史
727
+ * - ⚠️ 会丢弃该文件当前未暂存的修改
728
+ *
729
+ * 等价命令:git restore <file>
730
+ */
731
+ const restoreFile = async (file) => {
732
+ await runGit(["restore", file]);
733
+ };
734
+ /**
735
+ * 撤销【工作区】中所有未暂存的修改
736
+ * - 不影响暂存区
737
+ * - 不影响提交历史
738
+ * - ⚠️ 会丢弃所有未暂存的修改
739
+ *
740
+ * 等价命令:git restore .
741
+ */
742
+ const restoreAll = async () => {
743
+ await runGit(["restore", "."]);
744
+ };
745
+ /**
746
+ * 将某个文件从【暂存区】移回【工作区】
747
+ * - 保留文件修改
748
+ * - 仅取消 git add 的效果
749
+ *
750
+ * 等价命令:git restore --staged <file>
751
+ */
752
+ const unstageFile = async (file) => {
753
+ await runGit([
754
+ "restore",
755
+ "--staged",
756
+ file
757
+ ]);
758
+ };
759
+ /**
760
+ * 取消所有文件的暂存状态
761
+ * - 保留所有文件修改
762
+ * - 清空暂存区
763
+ *
764
+ * 等价命令:git restore --staged .
765
+ */
766
+ const unstageAll = async () => {
767
+ await runGit([
768
+ "restore",
769
+ "--staged",
770
+ "."
771
+ ]);
772
+ };
773
+ /**
774
+ * 丢弃某个文件的所有修改(暂存 + 未暂存)
775
+ * - 先取消暂存
776
+ * - 再撤销工作区修改
777
+ * - ⚠️ 修改内容将彻底丢失
778
+ *
779
+ * 等价操作:
780
+ * git restore --staged <file>
781
+ * git restore <file>
782
+ */
783
+ const discardFile = async (file) => {
784
+ await unstageFile(file);
785
+ await restoreFile(file);
786
+ };
787
+ /**
788
+ * 丢弃所有修改(包括暂存和未暂存的文件)
789
+ *
790
+ * 作用:
791
+ * - 将暂存区和工作区全部重置到当前 HEAD 提交状态
792
+ * - 丢弃所有未提交的更改,无法恢复(除非使用 reflog)
793
+ *
794
+ * ⚠️ 高风险操作,请确保已备份重要修改
795
+ *
796
+ * 等价命令:
797
+ * git reset --hard HEAD
798
+ */
799
+ const discardAll = async () => {
800
+ await runGit([
801
+ "reset",
802
+ "--hard",
803
+ "HEAD"
804
+ ]);
805
+ };
806
+ /**
807
+ * 撤销最近的 commit(软回退)
808
+ * - 提交历史回退
809
+ * - 修改内容保留
810
+ * - 修改仍在【暂存区】
811
+ *
812
+ * 常用于:刚提交但还想改点东西
813
+ *
814
+ * 等价命令:git reset --soft HEAD~<count>
815
+ */
816
+ const resetSoft = async (count = 1) => {
817
+ await runGit([
818
+ "reset",
819
+ "--soft",
820
+ `HEAD~${count}`
821
+ ]);
822
+ };
823
+ /**
824
+ * 撤销最近的 commit(混合回退,默认行为)
825
+ * - 提交历史回退
826
+ * - 修改内容保留
827
+ * - 修改回到【工作区】,不再暂存
828
+ *
829
+ * 等价命令:git reset --mixed HEAD~<count>
830
+ */
831
+ const resetMixed = async (count = 1) => {
832
+ await runGit([
833
+ "reset",
834
+ "--mixed",
835
+ `HEAD~${count}`
836
+ ]);
837
+ };
838
+ /**
839
+ * 撤销最近的 commit(强制回退)
840
+ * - 提交历史回退
841
+ * - ⚠️ 所有修改全部丢弃
842
+ *
843
+ * ⚠️ 高危操作,请谨慎使用
844
+ *
845
+ * 等价命令:git reset --hard HEAD~<count>
846
+ */
847
+ const resetHard = async (count = 1) => {
848
+ await runGit([
849
+ "reset",
850
+ "--hard",
851
+ `HEAD~${count}`
852
+ ]);
853
+ };
854
+ /**
855
+ * 安全撤销一个已提交(并可能已 push)的 commit
856
+ * - 不改写提交历史
857
+ * - 生成一个新的反向提交
858
+ *
859
+ * 适用于:公共分支 / 已推送到远端
860
+ *
861
+ * 等价命令:git revert <commit>
862
+ */
863
+ const revertCommit = async (hash) => {
864
+ await runGit(["revert", hash]);
865
+ };
866
+ /**
867
+ * 将某个文件恢复到指定 commit 的状态
868
+ * - 仅影响该文件
869
+ * - 会覆盖当前工作区中的该文件
870
+ * - 不自动提交
871
+ *
872
+ * 等价命令:git restore --source=<commit> <file>
873
+ */
874
+ const restoreFileFromCommit = async (file, commit) => {
875
+ await runGit([
876
+ "restore",
877
+ `--source=${commit}`,
878
+ file
879
+ ]);
880
+ };
881
+ /**
882
+ * 将当前目录下的所有文件恢复到指定 commit 的状态
883
+ *
884
+ * 行为说明:
885
+ * - 仅影响【工作区(Working Tree)】
886
+ * - 不影响暂存区(Index)
887
+ * - 不修改提交历史
888
+ *
889
+ * ⚠️ 风险提示:
890
+ * - 会覆盖当前工作区中的所有文件
891
+ * - 所有未暂存的修改将被永久丢弃
892
+ *
893
+ * 适用场景:
894
+ * - 批量回退文件内容到某个历史版本
895
+ * - 修复误操作、误格式化、误生成文件等情况
896
+ *
897
+ * 等价命令:
898
+ * git restore --source=<commit> .
899
+ */
900
+ const restoreFromCommit = async (commit) => {
901
+ await runGit([
902
+ "restore",
903
+ `--source=${commit}`,
904
+ "."
905
+ ]);
906
+ };
907
+ const deleteTag = async (tag) => {
908
+ await runGit([
909
+ "tag",
910
+ "--delete",
911
+ tag
912
+ ]);
913
+ };
914
+ /** {@link restoreAll} 的同步版本 */
915
+ const restoreAllSync = async () => {
916
+ runGitSync(["restore", "."]);
917
+ };
918
+ const deleteTagSync = async (tag) => {
919
+ runGitSync([
920
+ "tag",
921
+ "--delete",
922
+ tag
923
+ ]);
924
+ };
925
+ const discardAllSync = async () => {
926
+ runGitSync([
927
+ "reset",
928
+ "--hard",
929
+ "HEAD"
930
+ ]);
931
+ };
932
+ const resetHardSync = async (count = 1) => {
933
+ runGitSync([
934
+ "reset",
935
+ "--hard",
936
+ `HEAD~${count}`
937
+ ]);
938
+ };
939
+ const gitUndo = {
940
+ file: {
941
+ restore: restoreFile,
942
+ unstage: unstageFile,
943
+ discard: discardFile
944
+ },
945
+ all: {
946
+ restore: restoreAll,
947
+ unstage: unstageAll,
948
+ discard: discardAll
949
+ },
950
+ reset: {
951
+ soft: resetSoft,
952
+ mixed: resetMixed,
953
+ hard: resetHard
954
+ },
955
+ revert: revertCommit
956
+ };
957
+
958
+ //#endregion
959
+ //#region src/npm.ts
960
+ const DEFAULT_TAG = "latest";
961
+ const DEFAULT_ACCESS = "public";
962
+ const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
963
+ const accessArg = (access = DEFAULT_ACCESS) => {
964
+ return ["--access", access];
965
+ };
966
+ const registryArg = (registry = DEFAULT_REGISTRY) => {
967
+ return ["--registry", registry];
968
+ };
969
+ const tagArg = (tag = DEFAULT_TAG) => {
970
+ return ["--tag", tag];
971
+ };
972
+ /**
973
+ * 获取 npm registry
974
+ * @param {string} pkgDir
975
+ * @returns {Promise<string>}
976
+ * @defaults https://registry.npmjs.org/
977
+ */
978
+ const getRegistry = async (pkgDir) => {
979
+ const n = (r) => r.endsWith("/") ? r : `${r}/`;
980
+ const { publishConfig = {} } = readJsonFile(join(pkgDir, "package.json"));
981
+ const { registry } = publishConfig;
982
+ if (registry) return n(registry);
983
+ const res = await runNpm([
984
+ "config",
985
+ "get",
986
+ "registry"
987
+ ], { cwd: pkgDir });
988
+ if (res) return n(res);
989
+ return "https://registry.npmjs.org/";
990
+ };
991
+ /**
992
+ * 获取 publish access
993
+ * @param {string} pkgDir
994
+ * @returns {Promise<string>}
995
+ * @defaults scoped: restricted; unscoped: public
996
+ */
997
+ const getAccess = async (pkgDir) => {
998
+ const { name, publishConfig = {} } = readJsonFile(join(pkgDir, "package.json"));
999
+ const { access } = publishConfig;
1000
+ return access || (name.startsWith("@") ? "restricted" : "public");
1001
+ };
1002
+ /**
1003
+ * 检查与仓库的连接
1004
+ * @param {string} registry
1005
+ * @returns {Promise<boolean>}
1006
+ * @defaults npm ping --registry https://registry.npmjs.org/
1007
+ */
1008
+ const pingRegistry = async (registry) => {
1009
+ return void 0 !== await runNpm(["ping", ...registryArg(registry)]);
1010
+ };
1011
+ /**
1012
+ * 获取已登录用户
1013
+ * @param {string} registry
1014
+ * @returns {Promise<string | undefined>}
1015
+ * @defaults npm whoami --registry https://registry.npmjs.org/
1016
+ */
1017
+ const getAuthenticatedUser = (registry) => {
1018
+ return runNpm(["whoami", ...registryArg(registry)]);
1019
+ };
1020
+ /**
1021
+ * 用户是否拥有写入权限
1022
+ * @param {string} pkg
1023
+ * @param {string} user
1024
+ * @param {string} registry
1025
+ * @returns {Promise<boolean>}
1026
+ * @defaults npm access list collaborators <pkg> --json
1027
+ * npm access ls-collaborators <pkg> --json
1028
+ */
1029
+ const hasWriteAccess = async (pkg, user, registry) => {
1030
+ const res = await runNpm([
1031
+ "access",
1032
+ "list",
1033
+ "collaborators",
1034
+ pkg,
1035
+ "--json",
1036
+ ...registryArg(registry)
1037
+ ]).catch((_) => runNpm([
1038
+ "access",
1039
+ "ls-collaborators",
1040
+ pkg,
1041
+ "--json",
1042
+ ...registryArg(registry)
1043
+ ]));
1044
+ return (JSON.parse(res ?? "{}")[user] ?? "").includes("read-write");
1045
+ };
1046
+ /**
1047
+ * 获取指定包的版本
1048
+ * @param {string} pkg pkgName pkgName@tag
1049
+ * @param {string} registry
1050
+ * @returns {Promise<string | undefined>}
1051
+ * @defaults npm view <pkg> version
1052
+ */
1053
+ const getPublishedVersion = (pkg, registry) => {
1054
+ return runNpm([
1055
+ "view",
1056
+ pkg,
1057
+ "version",
1058
+ ...registryArg(registry)
1059
+ ]);
1060
+ };
1061
+ /**
1062
+ * 获取所有已发布的 dist-tags
1063
+ * @param {string} pkg
1064
+ * @param {string} registry
1065
+ * @returns {Promise<string[]>}
1066
+ * @defaults npm view <pkg> dist-tags --json
1067
+ */
1068
+ const getDistTags = async (pkg, registry) => {
1069
+ const res = await runNpm([
1070
+ "view",
1071
+ pkg,
1072
+ "dist-tags",
1073
+ "--json",
1074
+ ...registryArg(registry)
1075
+ ]);
1076
+ return Object.keys(JSON.parse(res || "{}"));
1077
+ };
1078
+ /**
1079
+ * 更新包版本号
1080
+ * @param {string} version
1081
+ * @param {string[]} args
1082
+ * @param {string} cwd
1083
+ * @returns {Promise<string | undefined>}
1084
+ * @defaults npm version <version> --workspaces=false --no-git-tag-version --allow-same-version
1085
+ */
1086
+ const bumpPackageVersion = (version, args = [], cwd = ".") => {
1087
+ return runNpm([
1088
+ "version",
1089
+ version,
1090
+ "--workspaces=false",
1091
+ "--no-git-tag-version",
1092
+ "--allow-same-version",
1093
+ ...args
1094
+ ], { cwd });
1095
+ };
1096
+ /**
1097
+ * 发布
1098
+ * @param {{access?: string, registry?: string, tag?: string, args?: string[], cwd?: string}} options
1099
+ * @returns {Promise<string | undefined>}
1100
+ * @defaults npm publish --tag latest --access public --registry https://registry.npmjs.org/ --workspaces=false
1101
+ */
1102
+ const publishPackage = (options) => {
1103
+ const { tag, access, registry, args = [], cwd = "." } = options ?? {};
1104
+ return runNpm([
1105
+ "publish",
1106
+ ...tagArg(tag),
1107
+ ...accessArg(access),
1108
+ ...registryArg(registry),
1109
+ "--workspaces=false",
1110
+ ...args
1111
+ ], { cwd });
1112
+ };
1113
+
1114
+ //#endregion
1115
+ export { ConfirmResult, DEFAULT_ACCESS, DEFAULT_REGISTRY, DEFAULT_TAG, HttpLibrary, PkgManager, YesOrNo, accessArg, bumpPackageVersion, checkVersion, coloredChangeset, copyDirAsync, countCommitsSinceLatestTag, deleteTag, deleteTagSync, discardAll, discardAllSync, discardFile, editFile, editJsonFile, emptyDir, eol, execAsync, execSyncWithString, fetchAllBranch, getAccess, getAllRemotes, getAuthenticatedUser, getCurrentBranch, getDefaultRemote, getDistTags, getFullHash, getGitConfig, getGitRemoteUrl, getGithubReleaseUrl, getGithubUrl, getLatestTag, getLocalTags, getLog, getOtherRemotes, getPackageInfo, getPackageUrl, getPreviousTag, getPublishedVersion, getRegistry, getRemote, getRemoteForBranch, getRemoteTags, getSortedTags, getStatus, getUpstreamArgs, gitAddAll, gitAddTracked, gitCommit, gitCommitAmend, gitTagAnnotated, gitTagLightweight, gitUndo, hasUpstreamBranch, hasWriteAccess, isEmpty, isGitRepo, isRemoteName, isTestFile, isValidPackageName, isWorkingDirClean, joinUrl, parseArgs, parseGitHubRepo, pingRegistry, pkgFromUserAgent, publishPackage, pushBranch, pushTag, readJsonFile, readSubDirs, registryArg, resetHard, resetHardSync, resetMixed, resetSoft, resolveChangelogRange, restoreAll, restoreAllSync, restoreFile, restoreFileFromCommit, restoreFromCommit, revertCommit, runCliForTest, runGit, runGitSync, runNode, runNodeSync, runNpm, runNpmSync, space, spawnAsync, spawnSyncWithString, stringifyArgs, tagArg, toValidPackageName, toValidProjectName, trimTemplate, unstageAll, unstageFile };