@peiyanlu/release 0.0.5 → 0.0.7

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) $YEAR $OWNER
3
+ Copyright (c) 2026 离殇
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -65,6 +65,18 @@ pnpm release minor --ci
65
65
  | `--show-changelog` | 打印生成的 Changelog 并退出 |
66
66
  | `--ci` | 启用 CI 模式:禁用交互提示,缺少必要参数时直接失败;GitHub Actions 等 CI/CD 环境中默认启用 |
67
67
 
68
+ ### Prepare 相关参数
69
+
70
+ | 参数 | 说明 |
71
+ |-------------|--------------------------|
72
+ | `--prepare` | 仅仅变更版本、提交代码、创建 Tag、推送到远程 |
73
+
74
+ ```bash
75
+ pnpm release minor --prepare --ci --package pkg-a
76
+ ```
77
+
78
+ > 后续可以通过 tag 进行 npm 发布和创建 GitHub release
79
+
68
80
  ### Monorepo / CI 相关参数
69
81
 
70
82
  | 参数 | 说明 |
@@ -72,7 +84,7 @@ pnpm release minor --ci
72
84
  | `-p, --package <pkg>` | 指定要发布的子包名称(仅用于 Monorepo 的 CI 场景) |
73
85
 
74
86
  ```bash
75
- pnpm release minor --ci --package test-a
87
+ pnpm release minor --ci --package pkg-a
76
88
  ```
77
89
 
78
90
  ### npm 发布相关参数
@@ -268,6 +280,36 @@ GitHub Release 可基于 Git Tag 自动创建,并支持附带:
268
280
 
269
281
  工具默认配置偏向于 **单仓库(Single Repo)** 项目,但是可以通过配置使其支持 **Monorepo**
270
282
 
283
+ ## 流程拆分
284
+
285
+ ### 本地准备 Release(仅创建 Tag)
286
+
287
+ 在本地完成版本确认与变更准备:
288
+
289
+ - 确定下一个版本
290
+ - 更新 `package.json` / `CHANGELOG`
291
+ - 创建发布提交
292
+ - 创建并推送 git 标签
293
+
294
+ ### CI/CD 自动发布(由 Tag 触发)
295
+
296
+ 当 Tag 推送到远端后,CI/CD 自动执行:
297
+
298
+ - 发布到 npm
299
+ - 创建 GItHub release
300
+
301
+ ```ts
302
+ // publishCI.ts
303
+ import { publishTagToNpm } from '@peiyanlu/release'
304
+
305
+
306
+ await publishTagToNpm({
307
+ gitTag: '1.0.16',
308
+ getPkgDir: () => '.',
309
+ defaultPackage: '.',
310
+ })
311
+ ```
312
+
271
313
  ## Dry Run
272
314
 
273
315
  ```bash
package/dist/cli.mjs CHANGED
@@ -1,24 +1,24 @@
1
1
  import path, { join, resolve } from "node:path";
2
2
  import { fileURLToPath, pathToFileURL } from "node:url";
3
- import { bumpPackageVersion, coloredChangeset, eol, execAsync, getAllRemotes, getAuthenticatedUser, getCurrentBranch, getGithubReleaseUrl, getGithubUrl, getLatestTag, getLog, getPackageInfo, getPackageUrl, getPublishedVersion, getRemote, getStatus, gitAddAll, gitAddTracked, gitCommit, gitTagAnnotated, hasWriteAccess, isGitRepo, isWorkingDirClean, parseGitHubRepo, pingRegistry, publishPackage, pushBranch, pushTag, readJsonFile, resolveChangelogRange, runGit } from "@peiyanlu/cli-utils";
3
+ import { bumpPackageVersion, coloredChangeset, eol, execAsync, getAllRemotes, getAuthenticatedUser, getCurrentBranch, getGithubReleaseUrl, getGithubUrl, getLatestTag, getLog, getPackageInfo, getPackageUrl, getPublishedVersion, getRemote, getStatus, gitAddAll, gitAddTracked, gitCommit, gitTagAnnotated, hasWriteAccess, isGitRepo, isPrerelease, isValid, isWorkingDirClean, parseGitHubRepo, parseVersion, pingRegistry, publishPackage, pushBranch, pushTag, readJsonFile, resolveChangelogRange } from "@peiyanlu/cli-utils";
4
4
  import { dim, green, red, rgb, underline, yellow } from "ansis";
5
5
  import { program } from "commander";
6
6
  import { createReadStream, createWriteStream, existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
7
7
  import { cancel, confirm, group, intro, isCancel, log, outro, select, tasks, text } from "@clack/prompts";
8
8
  import { publint } from "publint";
9
9
  import { formatMessage } from "publint/utils";
10
- import { coerce, eq, inc, lt, parse, prerelease, valid } from "semver";
11
- import { castArray, isPlainObject, isUndefined } from "@peiyanlu/ts-utils";
10
+ import { inc, lt, neq } from "semver";
11
+ import { castArray, isFunction, isPlainObject, isUndefined } from "@peiyanlu/ts-utils";
12
12
  import { spawnSync } from "node:child_process";
13
- import { Octokit } from "@octokit/rest";
14
- import { contentType } from "mime-types";
15
- import open from "open";
16
- import { glob } from "tinyglobby";
17
13
  import { inspect } from "node:util";
18
14
  import { ConventionalChangelog } from "conventional-changelog";
19
15
  import createPreset from "conventional-changelog-conventionalcommits";
20
16
  import { writeFile } from "node:fs/promises";
21
17
  import { finished } from "node:stream/promises";
18
+ import { Octokit } from "@octokit/rest";
19
+ import { contentType } from "mime-types";
20
+ import open from "open";
21
+ import { glob } from "tinyglobby";
22
22
 
23
23
  //#region node_modules/.pnpm/tsdown@0.18.4_publint@0.3.17_typescript@5.9.3/node_modules/tsdown/esm-shims.js
24
24
  const getFilename = () => fileURLToPath(import.meta.url);
@@ -55,7 +55,6 @@ const resolveConfig = async (cwd) => {
55
55
  config: await loadConfig(configFile)
56
56
  };
57
57
  };
58
- const isFunction = (v) => typeof v === "function";
59
58
  const mergeConfig = (defaults, overrides) => {
60
59
  if (!overrides) return structuredClone(defaults);
61
60
  if (Object.keys(defaults).length === 0) return structuredClone(overrides);
@@ -78,7 +77,9 @@ const n = (b) => b ? "(dry run)" : "";
78
77
  const t = () => green(`${Math.floor(process.uptime())}s`);
79
78
  const MSG = {
80
79
  INTRO: (dryRun) => `🚀 Starting release ${n(dryRun)}`,
80
+ INTRO_PREPARE: (dryRun) => `🚀 Starting prepare release ${n(dryRun)}`,
81
81
  OUTRO: (dryRun) => `🎉 Release finished successfully in ${t()} ${n(dryRun)}`,
82
+ OUTRO_PREPARE: (dryRun) => `🎉 Prepare release finished successfully in ${t()} ${n(dryRun)}`,
82
83
  LOG: {
83
84
  SHOW_VERSION: (version) => `🎉 Released ${green(version)}`,
84
85
  SHOW_CHANGELOG: "🎉 Changelog collected",
@@ -93,6 +94,10 @@ const MSG = {
93
94
  NPM: {
94
95
  CHECKING: "Checking npm registry",
95
96
  CHECKED: (registry, msg$1) => `NPM registry: ${underline(dim(registry))} ${underline(dim(msg$1))}`
97
+ },
98
+ GITHUB: {
99
+ CHECKING: "Checking github repository",
100
+ CHECKED: (repository, msg$1) => `GitHub repository: ${underline(dim(repository))} ${underline(dim(msg$1))}`
96
101
  }
97
102
  },
98
103
  TASK: {
@@ -151,123 +156,6 @@ const MSG = {
151
156
  }
152
157
  };
153
158
 
154
- //#endregion
155
- //#region src/github/release.ts
156
- const truncateBody = (body) => {
157
- if (body && body.length >= 124e3) return body.substring(0, 124e3) + "...";
158
- return body;
159
- };
160
- const uploadAssets = async (ctx, config) => {
161
- const { github: { assets } } = config;
162
- const { github: { isReleased, owner, repo, uploadUrl, releaseId, token }, pkg: { name } } = ctx;
163
- if (!assets?.length || !isReleased) return;
164
- const octokit = new Octokit({ auth: token });
165
- const files = await glob(assets);
166
- await Promise.all(files.map((file) => {
167
- return octokit.repos.uploadReleaseAsset({
168
- owner,
169
- repo,
170
- name,
171
- url: uploadUrl,
172
- release_id: releaseId,
173
- data: createReadStream(file),
174
- headers: {
175
- "content-type": contentType(file) || "application/octet-stream",
176
- "content-length": statSync(file).size
177
- }
178
- });
179
- }));
180
- };
181
- const createWebRelease = async (ctx, config) => {
182
- const { pkg: { toPreRelease }, github: { owner, repo, changelog, releaseName }, git: { currentTag } } = ctx;
183
- const { github: { prerelease: prerelease$1 } } = config;
184
- const github = getGithubUrl(owner, repo);
185
- const url = new URL(`${github}/releases/new`);
186
- url.searchParams.set("tag", currentTag);
187
- url.searchParams.set("title", releaseName);
188
- url.searchParams.set("body", truncateBody(changelog));
189
- url.searchParams.set("prelease", String(toPreRelease || prerelease$1));
190
- const isWindows = process.platform === "win32";
191
- await open(url.toString(), { wait: isWindows });
192
- };
193
- const createCiRelease = async (ctx, config) => {
194
- const { pkg: { toPreRelease }, github: { owner, repo, token, changelog, releaseName }, git: { currentTag } } = ctx;
195
- const { github: { prerelease: prerelease$1, draft, autoGenerate } } = config;
196
- const octokit = new Octokit({ auth: token });
197
- try {
198
- const { data } = await octokit.repos.createRelease({
199
- owner,
200
- repo,
201
- tag_name: currentTag,
202
- target_commitish: void 0,
203
- name: releaseName,
204
- body: autoGenerate ? "" : truncateBody(changelog),
205
- draft: draft ?? false,
206
- prerelease: (toPreRelease || prerelease$1) ?? false,
207
- discussion_category_name: void 0,
208
- generate_release_notes: autoGenerate,
209
- make_latest: "true"
210
- });
211
- const { html_url, upload_url, id, discussion_url } = data;
212
- Object.assign(ctx.github, {
213
- isReleased: true,
214
- releaseId: id,
215
- releaseUrl: html_url,
216
- uploadUrl: upload_url,
217
- discussionUrl: discussion_url
218
- });
219
- } catch (e) {
220
- if (e?.status === 422) throw new Error(MSG.ERROR.GITHUB_TAG_EXIT(currentTag));
221
- throw e;
222
- }
223
- };
224
- const parseGithubUrl = (ctx) => {
225
- const { git: { remoteUrl } } = ctx;
226
- const [owner, repo] = parseGitHubRepo(remoteUrl);
227
- Object.assign(ctx.github, {
228
- owner,
229
- repo
230
- });
231
- };
232
- const createRelease = async (ctx, config) => {
233
- const { github: { owner, repo }, isCI } = ctx;
234
- const { github: { tokenRef, release, skipChecks } } = config;
235
- if (!release) return;
236
- const token = tokenRef ? process.env[tokenRef] : process.env.GITHUB_TOKEN;
237
- if (!token) {
238
- Object.assign(ctx.github, { isWeb: true });
239
- if (isCI && process.env.GITHUB_ACTIONS) throw new Error(MSG.ERROR.GITHUB_TOKEN(tokenRef));
240
- }
241
- Object.assign(ctx.github, { token });
242
- const octokit = new Octokit({ auth: token });
243
- if (token && !skipChecks) if (process.env.GITHUB_ACTIONS) Object.assign(ctx.github, { username: process.env.GITHUB_ACTOR });
244
- else {
245
- try {
246
- const { data: { login: username } } = await octokit.users.getAuthenticated();
247
- Object.assign(ctx.github, { username });
248
- } catch (e) {
249
- throw new Error(MSG.ERROR.GITHUB_AUTH);
250
- }
251
- try {
252
- const { github: { username } } = ctx;
253
- await octokit.repos.checkCollaborator({
254
- owner,
255
- repo,
256
- username
257
- });
258
- } catch (e) {
259
- throw new Error(MSG.ERROR.GITHUB_USER);
260
- }
261
- }
262
- const { github: { isWeb }, dryRun } = ctx;
263
- if (dryRun) return true;
264
- if (isWeb) await createWebRelease(ctx, config);
265
- else if (isCI) {
266
- await createCiRelease(ctx, config);
267
- await uploadAssets(ctx, config);
268
- }
269
- };
270
-
271
159
  //#endregion
272
160
  //#region src/git/commit.ts
273
161
  const gitCheck = async (ctx, config) => {
@@ -289,7 +177,11 @@ const gitCheck = async (ctx, config) => {
289
177
  remoteName,
290
178
  remoteUrl
291
179
  });
292
- parseGithubUrl(ctx);
180
+ const [owner, repo] = parseGitHubRepo(remoteUrl);
181
+ Object.assign(ctx.github, {
182
+ owner,
183
+ repo
184
+ });
293
185
  };
294
186
  const commitAndTag = async (ctx, config) => {
295
187
  const { git: { remoteName, currentTag, commitMessage, tagMessage }, dryRun } = ctx;
@@ -406,37 +298,13 @@ const formatTemplate = async (ctx, config) => {
406
298
  Object.assign(ctx.github, { releaseName });
407
299
  };
408
300
 
409
- //#endregion
410
- //#region src/version/bump.ts
411
- const isPreRelease = (version) => {
412
- return Boolean(prerelease(version));
413
- };
414
- const isValid = (version) => {
415
- return Boolean(valid(version));
416
- };
417
- const parseVersion = (raw) => {
418
- const next = isValid(raw) ? raw : coerce(raw)?.toString();
419
- if (!next) return {};
420
- const { prerelease: prerelease$1 } = parse(next);
421
- const toPreRelease = Boolean(prerelease$1.length);
422
- const [preId, preBase] = prerelease$1.map(String);
423
- return {
424
- next,
425
- toPreRelease,
426
- preId,
427
- preBase
428
- };
429
- };
430
- const getCIVersion = (current) => {
431
- return inc(current, Release.PATCH) ?? "";
432
- };
433
-
434
301
  //#endregion
435
302
  //#region src/version/prompts.ts
436
303
  let Release = /* @__PURE__ */ function(Release$1) {
437
304
  Release$1["MAJOR"] = "major";
438
305
  Release$1["MINOR"] = "minor";
439
306
  Release$1["PATCH"] = "patch";
307
+ Release$1["RELEASE"] = "release";
440
308
  return Release$1;
441
309
  }({});
442
310
  let PreRelease = /* @__PURE__ */ function(PreRelease$1) {
@@ -456,76 +324,45 @@ let PreId = /* @__PURE__ */ function(PreId$1) {
456
324
  PreId$1["DEV"] = "dev";
457
325
  return PreId$1;
458
326
  }({});
327
+ const createVersionMap = (current) => ({
328
+ patch: inc(current, Release.PATCH),
329
+ minor: inc(current, Release.MINOR),
330
+ major: inc(current, Release.MAJOR),
331
+ release: inc(current, Release.RELEASE),
332
+ prerelease: inc(current, PreRelease.RELEASE),
333
+ prepatch: inc(current, PreRelease.PATCH, PreId.BETA),
334
+ preminor: inc(current, PreRelease.MINOR, PreId.BETA),
335
+ premajor: inc(current, PreRelease.MAJOR, PreId.BETA)
336
+ });
337
+ const option = (label, value, hint) => ({
338
+ label,
339
+ value,
340
+ hint: hint ?? value
341
+ });
459
342
  const runVersionPrompts = async (ctx, config) => {
460
343
  const { pkg: { current, fromPreRelease } } = ctx;
461
344
  const selectVersion = async () => {
462
- const patch = inc(current, Release.PATCH);
463
- const minor = inc(current, Release.MINOR);
464
- const major = inc(current, Release.MAJOR);
465
- const prerelease$1 = inc(current, PreRelease.RELEASE);
466
- const prepatch = inc(current, PreRelease.PATCH, PreId.BETA);
467
- const preminor = inc(current, PreRelease.MINOR, PreId.BETA);
468
- const premajor = inc(current, PreRelease.MAJOR, PreId.BETA);
469
- const pr = [{
470
- label: "Pre-Release",
471
- value: prerelease$1,
472
- hint: prerelease$1
473
- }];
474
- const pre = [
475
- {
476
- label: "Pre-Patch",
477
- value: prepatch,
478
- hint: prepatch
479
- },
480
- {
481
- label: "Pre-Minor",
482
- value: preminor,
483
- hint: preminor
484
- },
485
- {
486
- label: "Pre-Major",
487
- value: premajor,
488
- hint: premajor
489
- }
490
- ];
491
- const release = [
492
- {
493
- label: "Patch",
494
- value: patch,
495
- hint: patch
496
- },
497
- {
498
- label: "Minor",
499
- value: minor,
500
- hint: minor
501
- },
502
- {
503
- label: "Major",
504
- value: major,
505
- hint: major
506
- }
345
+ const versions = createVersionMap(current);
346
+ const options = [
347
+ ...fromPreRelease ? [option("Pre-Release", "prerelease", versions.prerelease)] : [],
348
+ option("Patch", "patch", versions.patch),
349
+ option("Minor", "minor", versions.minor),
350
+ option("Major", "major", versions.major),
351
+ ...!fromPreRelease ? [
352
+ option("Pre-Patch", "prepatch", versions.prepatch),
353
+ option("Pre-Minor", "preminor", versions.preminor),
354
+ option("Pre-Major", "premajor", versions.premajor)
355
+ ] : [],
356
+ option("As-Is", "ignore", current),
357
+ option("Custom", "custom", "custom specified")
507
358
  ];
508
- const custom = [{
509
- label: "As-Is",
510
- value: "ignore",
511
- hint: current
512
- }, {
513
- label: "Custom",
514
- value: "custom",
515
- hint: "custom specified"
516
- }];
517
- const version = await select({
359
+ const type = await select({
518
360
  message: question(MSG.PROMPT.SELECT_VERSION, "version"),
519
- options: [
520
- ...fromPreRelease ? pr : [],
521
- ...release,
522
- ...fromPreRelease ? [] : pre,
523
- ...custom
524
- ],
525
- initialValue: fromPreRelease ? prerelease$1 : patch
361
+ options,
362
+ initialValue: fromPreRelease ? "prerelease" : "patch"
526
363
  });
527
- abortSinglePrompt(version);
528
- return version;
364
+ abortSinglePrompt(type);
365
+ return type;
529
366
  };
530
367
  const inputVersion = async () => {
531
368
  const version = await text({
@@ -538,10 +375,12 @@ const runVersionPrompts = async (ctx, config) => {
538
375
  abortSinglePrompt(version);
539
376
  return version;
540
377
  };
541
- const type = await selectVersion();
542
- const next = type === "custom" ? await inputVersion() : type === "ignore" ? current : type;
543
- ctx.isIncrement = !eq(next, current);
544
- return { next };
378
+ const resolveNextVersion = async (type, current$1) => {
379
+ if (type === "ignore") return current$1;
380
+ if (type === "custom") return inputVersion();
381
+ return createVersionMap(current$1)[type];
382
+ };
383
+ return await resolveNextVersion(await selectVersion(), current);
545
384
  };
546
385
 
547
386
  //#endregion
@@ -644,8 +483,7 @@ const createDefaultConfig = (isCI) => {
644
483
  },
645
484
  npm: {
646
485
  publish: isCI,
647
- publishPath: ".",
648
- pushArgs: [],
486
+ publishArgs: [],
649
487
  skipChecks: false
650
488
  },
651
489
  github: {
@@ -758,11 +596,8 @@ const defaultTypes = [
758
596
 
759
597
  //#endregion
760
598
  //#region src/git/changelog.ts
761
- const generateChangelog = async ({ getPkgDir, tagPrefix }) => {
599
+ const createGenerator = async ({ getPkgDir, tagPrefix }) => {
762
600
  const pkgDir = getPkgDir();
763
- const infile = join(pkgDir, "CHANGELOG.md");
764
- if (!existsSync(infile)) await writeFile(infile, "");
765
- const originalChangelog = readFileSync(infile, "utf-8");
766
601
  const preset = await createPreset({ types: defaultTypes.map((t$1) => ({
767
602
  ...t$1,
768
603
  hidden: false
@@ -815,17 +650,30 @@ const generateChangelog = async ({ getPkgDir, tagPrefix }) => {
815
650
  {{> commit root=@root}}
816
651
  {{/each}}
817
652
  {{/each}}`.trim() + eol(2);
818
- const generator = new ConventionalChangelog().readPackage(`${pkgDir}/package.json`).config(preset).options({ releaseCount: 1 }).commits({ path: pkgDir }).tags({ prefix: tagPrefix });
819
- const writeStream = createWriteStream(infile);
653
+ return new ConventionalChangelog().readPackage(`${pkgDir}/package.json`).config(preset).options({ releaseCount: 1 }).commits({ path: pkgDir }).tags({ prefix: tagPrefix });
654
+ };
655
+ const getChangelog = async ({ getPkgDir, tagPrefix }) => {
656
+ const generator = await createGenerator({
657
+ getPkgDir,
658
+ tagPrefix
659
+ });
820
660
  let changelog = "";
821
- for await (const chunk of generator.write()) {
822
- changelog += chunk;
823
- writeStream.write(chunk);
824
- }
661
+ for await (const chunk of generator.write()) changelog += chunk;
662
+ return changelog;
663
+ };
664
+ const generateChangelog = async ({ getPkgDir, tagPrefix }) => {
665
+ const infile = join(getPkgDir(), "CHANGELOG.md");
666
+ if (!existsSync(infile)) await writeFile(infile, "");
667
+ const originalChangelog = readFileSync(infile, "utf-8");
668
+ const generator = await createGenerator({
669
+ getPkgDir,
670
+ tagPrefix
671
+ });
672
+ const writeStream = createWriteStream(infile);
673
+ for await (const chunk of generator.write()) writeStream.write(chunk);
825
674
  writeStream.write(originalChangelog);
826
675
  writeStream.end();
827
676
  await finished(writeStream);
828
- return changelog;
829
677
  };
830
678
 
831
679
  //#endregion
@@ -887,6 +735,172 @@ const runGithubPrompts = async (ctx, config) => {
887
735
  return res;
888
736
  };
889
737
 
738
+ //#endregion
739
+ //#region src/github/release.ts
740
+ const truncateBody = (body) => {
741
+ if (body && body.length >= 124e3) return body.substring(0, 124e3) + "...";
742
+ return body;
743
+ };
744
+ const getReleaseByTagSafe = async (octokit, options) => {
745
+ try {
746
+ const { data } = await octokit.repos.getReleaseByTag({ ...options });
747
+ return data;
748
+ } catch (e) {
749
+ if (e?.status === 404) return null;
750
+ throw e;
751
+ }
752
+ };
753
+ const createCRelease = async (octokit, options) => {
754
+ const { owner, repo, tag, name, body, draft, prerelease, generateReleaseNotes, makeLatest, targetCommitish, discussionCategoryName } = options;
755
+ const { data } = await octokit.repos.createRelease({
756
+ owner,
757
+ repo,
758
+ tag_name: tag,
759
+ name,
760
+ body,
761
+ draft,
762
+ prerelease,
763
+ generate_release_notes: generateReleaseNotes,
764
+ make_latest: makeLatest,
765
+ target_commitish: targetCommitish,
766
+ discussion_category_name: discussionCategoryName
767
+ });
768
+ return data;
769
+ };
770
+ const updateCRelease = async (octokit, options) => {
771
+ const { owner, repo, tag, name, body, draft, prerelease, generateReleaseNotes, makeLatest, targetCommitish, discussionCategoryName, releaseId } = options;
772
+ const { data } = await octokit.repos.updateRelease({
773
+ owner,
774
+ repo,
775
+ release_id: releaseId,
776
+ tag_name: tag,
777
+ name,
778
+ body,
779
+ draft,
780
+ prerelease,
781
+ generate_release_notes: generateReleaseNotes,
782
+ make_latest: makeLatest,
783
+ target_commitish: targetCommitish,
784
+ discussion_category_name: discussionCategoryName
785
+ });
786
+ return data;
787
+ };
788
+ const createCReleaseByTag = async (octokit, options) => {
789
+ const existing = await getReleaseByTagSafe(octokit, options);
790
+ if (!existing) return createCRelease(octokit, { ...options });
791
+ const { draft, id: releaseId } = existing;
792
+ if (!draft) throw new Error(`Release for tag "${options.tag}" already exists and is not a draft`);
793
+ return updateCRelease(octokit, {
794
+ releaseId,
795
+ ...options
796
+ });
797
+ };
798
+ const uploadAssets = async (octokit, options) => {
799
+ const { owner, repo, name, uploadUrl, releaseId, assets } = options;
800
+ const files = await glob(assets);
801
+ await Promise.all(files.map((file) => {
802
+ return octokit.repos.uploadReleaseAsset({
803
+ owner,
804
+ repo,
805
+ name,
806
+ url: uploadUrl,
807
+ release_id: releaseId,
808
+ data: createReadStream(file),
809
+ headers: {
810
+ "content-type": contentType(file) || "application/octet-stream",
811
+ "content-length": statSync(file).size
812
+ }
813
+ });
814
+ }));
815
+ };
816
+ const createWebRelease = async (options) => {
817
+ const { owner, repo, tag, title, body, prerelease } = options;
818
+ const url = new URL(`${getGithubUrl(owner, repo)}/releases/new`);
819
+ Object.entries({
820
+ tag,
821
+ title,
822
+ body,
823
+ prerelease
824
+ }).forEach(([key, value]) => {
825
+ url.searchParams.set(key, value);
826
+ });
827
+ const isWindows = process.platform === "win32";
828
+ await open(url.toString(), { wait: isWindows });
829
+ };
830
+ const githubCheck = async (ctx, config) => {
831
+ const { github: { owner, repo }, isCI } = ctx;
832
+ const { github: { tokenRef, skipChecks } } = config;
833
+ const token = tokenRef ? process.env[tokenRef] : process.env.GITHUB_TOKEN;
834
+ if (!token) {
835
+ Object.assign(ctx.github, { isWeb: true });
836
+ if (isCI && process.env.GITHUB_ACTIONS) throw new Error(MSG.ERROR.GITHUB_TOKEN(tokenRef));
837
+ return "";
838
+ }
839
+ Object.assign(ctx.github, { token });
840
+ if (skipChecks) return "";
841
+ let username = "";
842
+ if (process.env.GITHUB_ACTIONS) username = process.env.GITHUB_ACTOR;
843
+ else {
844
+ const octokit = new Octokit({ auth: token });
845
+ try {
846
+ const { data: { login } } = await octokit.users.getAuthenticated();
847
+ username = login;
848
+ } catch (e) {
849
+ throw new Error(MSG.ERROR.GITHUB_AUTH);
850
+ }
851
+ try {
852
+ await octokit.repos.checkCollaborator({
853
+ owner,
854
+ repo,
855
+ username
856
+ });
857
+ } catch (e) {
858
+ throw new Error(MSG.ERROR.GITHUB_USER);
859
+ }
860
+ }
861
+ return username;
862
+ };
863
+ const createRelease = async (ctx, config) => {
864
+ const { pkg: { name, toPreRelease }, git: { currentTag }, isCI, dryRun } = ctx;
865
+ const { github: { isWeb, token, owner, repo, changelog, releaseName } } = ctx;
866
+ const { github: { release, assets, prerelease, autoGenerate, draft } } = config;
867
+ if (!release || dryRun) return;
868
+ const octokit = new Octokit({ auth: token });
869
+ if (isWeb) await createWebRelease({
870
+ owner,
871
+ repo,
872
+ tag: currentTag,
873
+ title: releaseName,
874
+ body: truncateBody(changelog),
875
+ prerelease: String(toPreRelease || prerelease)
876
+ });
877
+ else if (isCI) {
878
+ const res = await createCReleaseByTag(octokit, {
879
+ owner,
880
+ repo,
881
+ tag: currentTag,
882
+ name: releaseName,
883
+ body: autoGenerate ? "" : truncateBody(changelog),
884
+ draft,
885
+ prerelease: toPreRelease || prerelease,
886
+ generateReleaseNotes: autoGenerate,
887
+ makeLatest: "true"
888
+ });
889
+ if (!res) return;
890
+ if (assets.length) {
891
+ const { id: releaseId, upload_url: uploadUrl } = res;
892
+ await uploadAssets(octokit, {
893
+ owner,
894
+ repo,
895
+ name,
896
+ uploadUrl,
897
+ releaseId,
898
+ assets
899
+ });
900
+ }
901
+ }
902
+ };
903
+
890
904
  //#endregion
891
905
  //#region src/npm/prompts.ts
892
906
  const runNpmPublishPrompts = async (ctx, config) => {
@@ -921,11 +935,11 @@ const runNpmOptPrompts = async (ctx, config) => {
921
935
  //#endregion
922
936
  //#region src/npm/publish.ts
923
937
  const resolvePublishTag = async (pkgName, version) => {
924
- const { toPreRelease, preId } = parseVersion(version);
925
- if (!toPreRelease) {
926
- const active = await getPublishedVersion(pkgName);
927
- return active && lt(version, active) ? "previous" : "latest";
928
- } else return preId || "prerelease";
938
+ const { isPrerelease: isPrerelease$1, preId } = parseVersion(version);
939
+ if (isPrerelease$1) return preId || "next";
940
+ const active = await getPublishedVersion(pkgName);
941
+ if (!active) return "latest";
942
+ return lt(version, active) ? "previous" : "latest";
929
943
  };
930
944
  const isOtpError = (err) => err instanceof Error && /one-time password|otp/i.test(err.message);
931
945
  const npmCheck = async (ctx, config) => {
@@ -946,7 +960,7 @@ const npmCheck = async (ctx, config) => {
946
960
  };
947
961
  const publishNpm = async (ctx, config) => {
948
962
  const { dryRun, selectedPkg, npm: { otp, tag } } = ctx;
949
- const { npm: { publish, pushArgs }, getPkgDir } = config;
963
+ const { npm: { publish, publishArgs }, getPkgDir } = config;
950
964
  if (!publish) return;
951
965
  const otpArgs = otp ? ["--otp", otp] : [];
952
966
  const dryRunArg = dryRun ? ["--dry-run"] : [];
@@ -954,7 +968,7 @@ const publishNpm = async (ctx, config) => {
954
968
  tag,
955
969
  args: [
956
970
  ...otpArgs,
957
- ...pushArgs,
971
+ ...publishArgs,
958
972
  ...dryRunArg
959
973
  ],
960
974
  cwd: getPkgDir(selectedPkg)
@@ -964,7 +978,27 @@ const publishNpm = async (ctx, config) => {
964
978
  //#endregion
965
979
  //#region src/action.ts
966
980
  var Action = class {
967
- async handle(cmdArgs, options) {
981
+ async handleRelease(cmdArgs, options) {
982
+ const { ctx, config } = await this.createContext(cmdArgs, options);
983
+ await this.checkTask(ctx, config);
984
+ await this.bumpTask(ctx, config);
985
+ await this.changelogTask(ctx, config);
986
+ await this.gitTask(ctx, config);
987
+ await this.npmTask(ctx, config);
988
+ await this.githubTask(ctx, config);
989
+ if (ctx.dryRun) gitRollback(ctx);
990
+ taskEnd(MSG.OUTRO(ctx.dryRun));
991
+ }
992
+ async handlePrepareRelease(cmdArgs, options) {
993
+ const { ctx, config } = await this.createContext(cmdArgs, options, true);
994
+ await this.checkTask(ctx, config);
995
+ await this.bumpTask(ctx, config);
996
+ await this.changelogTask(ctx, config);
997
+ await this.gitTask(ctx, config);
998
+ if (ctx.dryRun) gitRollback(ctx);
999
+ taskEnd(MSG.OUTRO_PREPARE(ctx.dryRun));
1000
+ }
1001
+ async createContext(cmdArgs, options, prepare = false) {
968
1002
  const { version: cVersion, name: cName } = readJsonFile(join(__dirname, "../package.json"));
969
1003
  const { otp, package: defPkg, ...others } = options;
970
1004
  const { showChangelog, showRelease, ci, dryRun } = Object.fromEntries(Object.entries(others).map(([k, v]) => [k, Boolean(v)]));
@@ -974,7 +1008,7 @@ var Action = class {
974
1008
  info(MSG.INFO.TOOL(cName, cVersion));
975
1009
  info(MSG.INFO.CONFIG(configPath));
976
1010
  console.log();
977
- intro(MSG.INTRO(dryRun));
1011
+ intro(prepare ? MSG.INTRO_PREPARE(dryRun) : MSG.INTRO(dryRun));
978
1012
  const { isMonorepo, packages, getPkgDir } = config;
979
1013
  if (isMonorepo) {
980
1014
  if (ci) {
@@ -1015,7 +1049,7 @@ var Action = class {
1015
1049
  pkg: {
1016
1050
  name: pkgName,
1017
1051
  isPrivate: pkgPrivate,
1018
- fromPreRelease: isPreRelease(pkgVersion),
1052
+ fromPreRelease: isPrerelease(pkgVersion),
1019
1053
  current: pkgVersion,
1020
1054
  next: nextVersion,
1021
1055
  toPreRelease: false,
@@ -1028,33 +1062,42 @@ var Action = class {
1028
1062
  const formated = formatMessage(message, pkg$1);
1029
1063
  formated && msg("PKG", formated);
1030
1064
  }
1031
- await this.checkTask(ctx, config);
1032
- await this.bumpTask(ctx, config);
1033
- await this.changelogTask(ctx, config);
1034
- await this.gitTask(ctx, config);
1035
- await this.npmTask(ctx, config);
1036
- await this.githubTask(ctx, config);
1037
- if (dryRun) gitRollback(ctx);
1038
- taskEnd(MSG.OUTRO(dryRun));
1065
+ return {
1066
+ ctx,
1067
+ config
1068
+ };
1039
1069
  }
1040
1070
  async checkTask(ctx, config) {
1041
- const { noNpm, dryRun } = ctx;
1042
- await tasks([{
1043
- title: MSG.CHECK.GIT.CHECKING,
1044
- task: async () => {
1045
- await gitCheck(ctx, config);
1046
- const { git: { remoteName, remoteUrl } } = ctx;
1047
- return success(MSG.CHECK.GIT.CHECKED(remoteName, remoteUrl), dryRun);
1048
- }
1049
- }, {
1050
- title: MSG.CHECK.NPM.CHECKING,
1051
- task: async () => {
1052
- const msg$1 = await npmCheck(ctx, config);
1053
- const { pkg: { publishConfig: { registry } } } = ctx;
1054
- return success(MSG.CHECK.NPM.CHECKED(registry, msg$1), dryRun);
1071
+ const { dryRun } = ctx;
1072
+ await tasks([
1073
+ {
1074
+ title: MSG.CHECK.GIT.CHECKING,
1075
+ task: async () => {
1076
+ await gitCheck(ctx, config);
1077
+ const { git: { remoteName, remoteUrl } } = ctx;
1078
+ return success(MSG.CHECK.GIT.CHECKED(remoteName, remoteUrl), dryRun);
1079
+ }
1055
1080
  },
1056
- enabled: !noNpm
1057
- }]).catch(abortOnError);
1081
+ {
1082
+ title: MSG.CHECK.NPM.CHECKING,
1083
+ task: async () => {
1084
+ const msg$1 = await npmCheck(ctx, config);
1085
+ const { pkg: { publishConfig: { registry } } } = ctx;
1086
+ return success(MSG.CHECK.NPM.CHECKED(registry, msg$1), dryRun);
1087
+ },
1088
+ enabled: !ctx.noNpm
1089
+ },
1090
+ {
1091
+ title: MSG.CHECK.GITHUB.CHECKING,
1092
+ task: async () => {
1093
+ const msg$1 = await githubCheck(ctx, config);
1094
+ const { github: { owner, repo } } = ctx;
1095
+ const repository = getGithubUrl(owner, repo);
1096
+ return success(MSG.CHECK.GITHUB.CHECKED(repository, msg$1), dryRun);
1097
+ },
1098
+ enabled: !ctx.noGitHub
1099
+ }
1100
+ ]).catch(abortOnError);
1058
1101
  }
1059
1102
  async bumpTask(ctx, config) {
1060
1103
  const { pkg: { current }, dryRun, isCI, showRelease, showChangelog, selectedPkg } = ctx;
@@ -1064,8 +1107,15 @@ var Action = class {
1064
1107
  return !next$1 && isIncrement$1;
1065
1108
  };
1066
1109
  if (need(ctx)) {
1067
- const parsed = parseVersion(isCI && getCIVersion(current) || (await runVersionPrompts(ctx, config)).next);
1068
- Object.assign(ctx.pkg, { ...parsed });
1110
+ const nextVersion = (isCI ? inc(current, "patch") : void 0) || await runVersionPrompts(ctx, config);
1111
+ ctx.isIncrement = neq(current, nextVersion);
1112
+ const { version: next$1, isPrerelease: toPreRelease, preId, preBase } = parseVersion(nextVersion);
1113
+ Object.assign(ctx.pkg, {
1114
+ next: next$1,
1115
+ toPreRelease,
1116
+ preId,
1117
+ preBase
1118
+ });
1069
1119
  }
1070
1120
  const { pkg: { next }, isIncrement } = ctx;
1071
1121
  if (showRelease) taskEnd(MSG.LOG.SHOW_VERSION(next));
@@ -1081,9 +1131,15 @@ var Action = class {
1081
1131
  }]).catch((err) => abortOnError(err, ctx));
1082
1132
  await runLifeCycleHook(hooks, "after:bump", dryRun);
1083
1133
  const { from, to } = await resolveChangelogRange(isIncrement, isMonorepo ? `${tagPrefix?.(selectedPkg)}*` : "*");
1084
- const logStr = await getLog(from, to, getPkgDir(selectedPkg));
1085
- if (logStr) {
1086
- msg("GIT", `Changelog(${from}...${to}):${eol(2)}` + logStr);
1134
+ const commits = await getLog(from, to, getPkgDir(selectedPkg));
1135
+ const changelog = await getChangelog({
1136
+ getPkgDir: () => getPkgDir(selectedPkg),
1137
+ tagPrefix: tagPrefix?.(selectedPkg)
1138
+ });
1139
+ Object.assign(ctx.github, { changelog });
1140
+ if (commits) {
1141
+ msg("GIT", `Changelog(${from}...${to}):${eol(2)}` + changelog.trimEnd());
1142
+ msg("GIT", `Commits(${from}...${to}):${eol(2)}` + commits);
1087
1143
  if (showChangelog) taskEnd(MSG.LOG.SHOW_CHANGELOG);
1088
1144
  } else msg("GIT", MSG.LOG.CHANGELOG_EMPTY);
1089
1145
  }
@@ -1093,12 +1149,11 @@ var Action = class {
1093
1149
  await tasks([{
1094
1150
  title: MSG.TASK.CHANGELOG.START,
1095
1151
  task: async () => {
1096
- const changelog = await generateChangelog({
1152
+ await generateChangelog({
1097
1153
  getPkgDir: () => getPkgDir(selectedPkg),
1098
1154
  tagPrefix: tagPrefix?.(selectedPkg)
1099
1155
  });
1100
- await runGit(["add", `${getPkgDir(selectedPkg)}/CHANGELOG.md`]);
1101
- Object.assign(ctx.github, { changelog });
1156
+ await gitAddAll();
1102
1157
  return success(MSG.TASK.CHANGELOG.END, dryRun);
1103
1158
  },
1104
1159
  enabled: isIncrement
@@ -1165,8 +1220,10 @@ var Action = class {
1165
1220
  //#endregion
1166
1221
  //#region src/cli.ts
1167
1222
  const pkg = readJsonFile(join(__dirname, "..", "package.json"));
1168
- program.name("release").description(pkg.description).version(pkg.version, "-v, --version", "Print the tool version and exit.").usage("[release-type] [options]").argument("[release-type]", "Version bump type: patch | minor | major").option("-n, --dry-run", "Run in dry mode: show what would be released without making any changes.", false).option("-p, --package <pkg>", "Specify package name for mono-repo release (CI only).", "").option("--otp <code>", "One-time password for npm publish (used for 2FA).", "").option("--ci", "Enable CI mode: disable prompts and fail on missing required options.", false).option("--show-changelog", "Print the generated changelog and exit.", false).option("--show-release", "Print the version that would be released and exit.", false).helpOption("-h, --help", "Display help information.").action(async (releaseType, options) => {
1169
- await new Action().handle(releaseType, options);
1223
+ program.name("release").description(pkg.description).version(pkg.version, "-v, --version", "Print the tool version and exit.").usage("[release-type] [options]").argument("[release-type]", "Version bump type: patch | minor | major").option("-n, --dry-run", "Run in dry mode", false).option("-p, --package <pkg>", "Specify package name (CI only).", "").option("--otp <code>", "One-time password for npm publish.", "").option("--prepare", "Prepare a release.", false).option("--ci", "Enable CI mode.", false).option("--show-changelog", "Print changelog and exit.", false).option("--show-release", "Print release version and exit.", false).helpOption("-h, --help", "Display help information.").action(async (releaseType, options) => {
1224
+ const { prepare } = options;
1225
+ const action = new Action();
1226
+ prepare ? await action.handlePrepareRelease(releaseType, options) : await action.handleRelease(releaseType, options);
1170
1227
  });
1171
1228
  program.command("init").description("Create a release configuration file").option("-f, --force", "Overwrite existing config file", false).option("-m, --monorepo", "Mono-repo project", false).action((options) => {
1172
1229
  const { force = false, monorepo = false } = options;
@@ -1174,9 +1231,10 @@ program.command("init").description("Create a release configuration file").optio
1174
1231
  const { type } = readJsonFile(join(cwd, "package.json"));
1175
1232
  const isESM = type === "module";
1176
1233
  const isTS = existsSync(join(cwd, "tsconfig.json"));
1177
- const configPath = join(cwd, `release.config.${isESM ? "" : "m"}${isTS ? "ts" : "js"}`);
1234
+ const infile = `release.config.${isESM ? "" : "m"}${isTS ? "ts" : "js"}`;
1235
+ const configPath = join(cwd, infile);
1178
1236
  if (existsSync(configPath) && !force) {
1179
- console.error(`${red`Error:`} ${yellow`release.config.ts`} already exists.\nUse ${green`--force`} to overwrite.`);
1237
+ console.error(`${red`Error:`} ${yellow(infile)} already exists.\nUse ${green`--force`} to overwrite.`);
1180
1238
  process.exit(1);
1181
1239
  }
1182
1240
  const monoConfigToString = () => `{
@@ -1186,10 +1244,8 @@ program.command("init").description("Create a release configuration file").optio
1186
1244
  toTag: (pkg, version) => \`\${ pkg }@\${ version }\`,
1187
1245
  tagPrefix: (pkg) => \`\${ pkg }@\`,
1188
1246
  }`;
1189
- writeFileSync(configPath, `import { defineConfig } from '@peiyanlu/release'
1190
-
1191
- export default defineConfig(${monorepo ? monoConfigToString() : "{}"})`, "utf-8");
1192
- console.log(`${green`✔ release.config.ts`} created successfully`);
1247
+ writeFileSync(configPath, [`import { defineConfig } from '@peiyanlu/release'`, `export default defineConfig(${monorepo ? monoConfigToString() : "{}"})`].join(eol(2)), "utf-8");
1248
+ console.log(`${green`✔`} ${infile} created successfully`);
1193
1249
  });
1194
1250
  program.parse(process.argv);
1195
1251
  program.on("command:*", () => {
package/dist/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { DeepPartial, DeepRequired } from "@peiyanlu/ts-utils";
2
+ import { ConventionalChangelog } from "conventional-changelog";
2
3
  import { log } from "@clack/prompts";
3
4
 
4
5
  //#region src/types.d.ts
@@ -88,14 +89,10 @@ interface ReleaseConfig {
88
89
  * 是否启用 npm 发布流程
89
90
  */
90
91
  publish: boolean | undefined;
91
- /**
92
- * npm 发布目标路径,对应 `npm publish <package-spec>`
93
- */
94
- publishPath: string;
95
92
  /**
96
93
  * 额外的 npm publish 参数(透传给 npm CLI)
97
94
  */
98
- pushArgs: string[];
95
+ publishArgs: string[];
99
96
  /**
100
97
  * 是否跳过发布前检查(如 ping、whoami 等校验)
101
98
  */
@@ -155,4 +152,48 @@ declare function defineConfig(config: UserConfigFn): UserConfigFn;
155
152
  declare function defineConfig(config: UserConfigExport): UserConfigExport;
156
153
  declare const mergeConfig: <T extends object>(defaults: DeepRequired<T>, overrides?: DeepPartial<T>) => DeepRequired<T>;
157
154
  //#endregion
158
- export { type Logger, type UserConfig, defineConfig, mergeConfig };
155
+ //#region src/git/changelog.d.ts
156
+ interface Options {
157
+ /** @example () => `packages/${pkg}` */
158
+ getPkgDir: () => string;
159
+ /** @example `${pkg}@` */
160
+ tagPrefix?: string;
161
+ }
162
+ declare const createGenerator: ({
163
+ getPkgDir,
164
+ tagPrefix
165
+ }: Options) => Promise<ConventionalChangelog>;
166
+ declare const getChangelog: ({
167
+ getPkgDir,
168
+ tagPrefix
169
+ }: Options) => Promise<string>;
170
+ declare const generateChangelog: ({
171
+ getPkgDir,
172
+ tagPrefix
173
+ }: Options) => Promise<void>;
174
+ //#endregion
175
+ //#region src/index.d.ts
176
+ interface PublishTagOptions {
177
+ /**
178
+ * 支持通过命令行参数提供
179
+ * @example v1.0.0; pkg@1.0.1;
180
+ * @example my-cli v1.0.0;
181
+ */
182
+ gitTag?: string;
183
+ /** @defaults `@` */
184
+ tagSeparator?: string;
185
+ /**
186
+ * monorepo 仓库子包不使用 `pkg@version` 格式创建 tag 时需要指定
187
+ * @example pkg-demo
188
+ */
189
+ defaultPackage?: string;
190
+ /**
191
+ * @example (pkg) => `packages/${pkg}`; (pkg) => '.';
192
+ */
193
+ getPkgDir?: (pkg: string) => string;
194
+ /** npm Trusted Publishing(OIDC) */
195
+ provenance?: boolean;
196
+ }
197
+ declare const publishTagToNpm: (options: PublishTagOptions) => Promise<void>;
198
+ //#endregion
199
+ export { type Logger, type UserConfig, createGenerator, defineConfig, generateChangelog, getChangelog, mergeConfig, publishTagToNpm };
package/dist/index.mjs CHANGED
@@ -1,10 +1,113 @@
1
- import { isPlainObject } from "@peiyanlu/ts-utils";
1
+ import { eol, getPackageInfo, getPublishedVersion, parseVersion, publishPackage } from "@peiyanlu/cli-utils";
2
+ import { blue, cyan, dim, green, magenta, red, underline, yellow } from "ansis";
3
+ import { lt } from "semver";
4
+ import { isFunction, isPlainObject } from "@peiyanlu/ts-utils";
5
+ import { createWriteStream, existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { ConventionalChangelog } from "conventional-changelog";
8
+ import createPreset from "conventional-changelog-conventionalcommits";
9
+ import { writeFile } from "node:fs/promises";
10
+ import { finished } from "node:stream/promises";
2
11
 
12
+ //#region src/messages.ts
13
+ const n = (b) => b ? "(dry run)" : "";
14
+ const t = () => green(`${Math.floor(process.uptime())}s`);
15
+ const MSG = {
16
+ INTRO: (dryRun) => `🚀 Starting release ${n(dryRun)}`,
17
+ INTRO_PREPARE: (dryRun) => `🚀 Starting prepare release ${n(dryRun)}`,
18
+ OUTRO: (dryRun) => `🎉 Release finished successfully in ${t()} ${n(dryRun)}`,
19
+ OUTRO_PREPARE: (dryRun) => `🎉 Prepare release finished successfully in ${t()} ${n(dryRun)}`,
20
+ LOG: {
21
+ SHOW_VERSION: (version) => `🎉 Released ${green(version)}`,
22
+ SHOW_CHANGELOG: "🎉 Changelog collected",
23
+ CHANGELOG_EMPTY: "No commits found since last release",
24
+ CHANGES_EMPTY: "Working tree clean. No files changed"
25
+ },
26
+ CHECK: {
27
+ GIT: {
28
+ CHECKING: "Checking Git repository",
29
+ CHECKED: (name, url) => `Git remote resolved: ${url ? `${dim(name)} → ${underline(dim(url))}` : "not found"}`
30
+ },
31
+ NPM: {
32
+ CHECKING: "Checking npm registry",
33
+ CHECKED: (registry, msg) => `NPM registry: ${underline(dim(registry))} ${underline(dim(msg))}`
34
+ },
35
+ GITHUB: {
36
+ CHECKING: "Checking github repository",
37
+ CHECKED: (repository, msg) => `GitHub repository: ${underline(dim(repository))} ${underline(dim(msg))}`
38
+ }
39
+ },
40
+ TASK: {
41
+ VERSION: {
42
+ START: "Bumping version",
43
+ END: (to) => `Version bumped: ${underline(dim(to))}`
44
+ },
45
+ CHANGELOG: {
46
+ START: "Generating changelog",
47
+ END: `Changelog generated`
48
+ },
49
+ NPM: {
50
+ START: "Publishing npm package",
51
+ END: (url) => `NPM package published: ${underline(dim(url))}`
52
+ },
53
+ GIT: {
54
+ START: "Creating git commit, tag & push",
55
+ END: "Git commit, tag & push completed"
56
+ },
57
+ GITHUB: {
58
+ START: "Creating GitHub release",
59
+ END: (url) => `GitHub release created: ${underline(dim(url))}`
60
+ }
61
+ },
62
+ PROMPT: {
63
+ SELECT_PACKAGE: "Select package release:",
64
+ SELECT_VERSION: "Select version bump:",
65
+ INPUT_VERSION: "Enter custom version:",
66
+ NPM_PUBLISH: (pkg) => `Publish to npm ${underline(dim`(${pkg})`)}?`,
67
+ NPM_OTP: "Enter npm one-time password (OTP):",
68
+ GITHUB_RELEASE: (title) => `Create a new GitHub release ${underline(dim`(${title})`)}?`,
69
+ GIT_COMMIT: (msg) => `Create git commit ${underline(dim`(${msg})`)}?`,
70
+ GIT_TAG: (msg) => `Create git tag ${underline(dim`(${msg})`)}?`,
71
+ GIT_PUSH: "Push commits and tags to remote?"
72
+ },
73
+ ERROR: {
74
+ GIT_WORKDIR: `[git] Working dir must be clean`,
75
+ GIT_REGISTRY: (name) => `[git] Repository not found for ${dim(name)}`,
76
+ GIT_REMOTE: (name) => `[git] Remote "${dim(name)}" not found`,
77
+ GITHUB_TAG_EXIT: (tag) => `[github] Release or tag "${tag}" already exists`,
78
+ GITHUB_TOKEN: (tokenRef) => `[github] Missing GitHub token. Please set ${yellow(tokenRef ?? "GITHUB_TOKEN")}`,
79
+ GITHUB_AUTH: "[github] Invalid GitHub token or insufficient permissions",
80
+ GITHUB_USER: "[github] User does not have permission to create releases",
81
+ NPM_REGISTRY: (registry) => `[npm] Unable to reach npm registry ${underline(dim(registry))}`,
82
+ NPM_AUTH: `[npm] Not authenticated with npm. Please run ${yellow("npm login")} and try again.`,
83
+ NPM_USER: (user, name) => `[npm] User ${dim(user)} is not a collaborator of ${dim(name)}.`
84
+ },
85
+ ABORT: {
86
+ CANCEL: "Operation cancelled",
87
+ MONOREPO_CI_NO_PACKAGE: "CI mode requires a target package in monorepo. Please specify it via \"--package <pkg>\"",
88
+ MONOREPO_NO_PACKAGES: "Monorepo detected, but no packages found. Please configure \"config.packages\""
89
+ },
90
+ INFO: {
91
+ TOOL: (name, version) => `${name} ${dim(`v${version}`)}`,
92
+ CONFIG: (path) => `config file: ${underline(dim(path || "not found"))}`
93
+ }
94
+ };
95
+
96
+ //#endregion
97
+ //#region src/npm/publish.ts
98
+ const resolvePublishTag = async (pkgName, version) => {
99
+ const { isPrerelease, preId } = parseVersion(version);
100
+ if (isPrerelease) return preId || "next";
101
+ const active = await getPublishedVersion(pkgName);
102
+ if (!active) return "latest";
103
+ return lt(version, active) ? "previous" : "latest";
104
+ };
105
+
106
+ //#endregion
3
107
  //#region src/config.ts
4
108
  function defineConfig(config) {
5
109
  return config;
6
110
  }
7
- const isFunction = (v) => typeof v === "function";
8
111
  const mergeConfig = (defaults, overrides) => {
9
112
  if (!overrides) return structuredClone(defaults);
10
113
  if (Object.keys(defaults).length === 0) return structuredClone(overrides);
@@ -22,4 +125,218 @@ const mergeConfig = (defaults, overrides) => {
22
125
  };
23
126
 
24
127
  //#endregion
25
- export { defineConfig, mergeConfig };
128
+ //#region src/git/changetype.ts
129
+ const defaultTypes = [
130
+ {
131
+ type: "feat",
132
+ section: "✨ 新功能",
133
+ description: "新增功能"
134
+ },
135
+ {
136
+ type: "feature",
137
+ section: "✨ 新功能",
138
+ description: "新增功能"
139
+ },
140
+ {
141
+ type: "fix",
142
+ section: "🐛 Bug 修复",
143
+ description: "修复 bug"
144
+ },
145
+ {
146
+ type: "perf",
147
+ section: "⚡ 性能优化",
148
+ description: "提升性能"
149
+ },
150
+ {
151
+ type: "revert",
152
+ section: "⏪ 回退",
153
+ description: "回退到之前版本"
154
+ },
155
+ {
156
+ type: "docs",
157
+ section: "📝 文档",
158
+ description: "文档更新"
159
+ },
160
+ {
161
+ type: "style",
162
+ section: "💄 样式调整",
163
+ description: "样式或格式修改"
164
+ },
165
+ {
166
+ type: "chore",
167
+ section: "🎫 其他更新",
168
+ description: "非功能性更改"
169
+ },
170
+ {
171
+ type: "refactor",
172
+ section: "♻ 代码重构",
173
+ description: "重构代码"
174
+ },
175
+ {
176
+ type: "test",
177
+ section: "✅ 测试",
178
+ description: "测试更新"
179
+ },
180
+ {
181
+ type: "build",
182
+ section: "👷‍ 构建系统",
183
+ description: "构建工具和系统修改"
184
+ },
185
+ {
186
+ type: "ci",
187
+ section: "🔧 CI 配置",
188
+ description: "持续集成配置"
189
+ },
190
+ {
191
+ type: "config",
192
+ section: "🔨 配置",
193
+ description: "配置文件更新"
194
+ },
195
+ {
196
+ type: "deps",
197
+ section: "🔗 依赖更新",
198
+ description: "依赖版本变更"
199
+ },
200
+ {
201
+ type: "security",
202
+ section: "🔒 安全修复",
203
+ description: "安全性更新"
204
+ },
205
+ {
206
+ type: "i18n",
207
+ section: "🌐 国际化",
208
+ description: "国际化相关更新"
209
+ },
210
+ {
211
+ type: "ux",
212
+ section: "🖥️ 用户体验",
213
+ description: "用户体验改进"
214
+ },
215
+ {
216
+ type: "hotfix",
217
+ section: "🔥 热修复",
218
+ description: "紧急修复"
219
+ }
220
+ ];
221
+
222
+ //#endregion
223
+ //#region src/git/changelog.ts
224
+ const createGenerator = async ({ getPkgDir, tagPrefix }) => {
225
+ const pkgDir = getPkgDir();
226
+ const preset = await createPreset({ types: defaultTypes.map((t$1) => ({
227
+ ...t$1,
228
+ hidden: false
229
+ })) });
230
+ preset.writer ??= {};
231
+ preset.writer.headerPartial = `
232
+ ## {{#if isPatch~}} <small> {{~/if~}}
233
+ {{#if @root.linkCompare~}}
234
+ [{{version}}](
235
+ {{~#if @root.repository~}}
236
+ {{~#if @root.host}}
237
+ {{~@root.host}}/
238
+ {{~/if}}
239
+ {{~#if @root.owner}}
240
+ {{~@root.owner}}/
241
+ {{~/if}}
242
+ {{~@root.repository}}
243
+ {{~else}}
244
+ {{~@root.repoUrl}}
245
+ {{~/if~}}
246
+ /compare/{{previousTag}}...{{currentTag}})
247
+ {{~else}}
248
+ {{~version}}
249
+ {{~/if}}
250
+ {{~#if title}} "{{title}}"
251
+ {{~/if}}
252
+ {{~#if date}} ({{date}})
253
+ {{~/if}}
254
+ {{~#if isPatch~}} </small> {{~/if}}
255
+ `.trim() + eol();
256
+ preset.writer.mainTemplate = `
257
+ {{> header}}
258
+ {{#if noteGroups}}
259
+ {{#each noteGroups}}
260
+
261
+ ### ⚠ {{title}}
262
+
263
+ {{#each notes}}
264
+ * {{#if commit.scope}}**{{commit.scope}}:** {{/if}}{{commit.subject}} {{#if commit.hash}}([{{commit.shortHash}}](https://github.com/{{@root.owner}}/{{@root.repository}}/commit/{{commit.hash}})){{/if}}
265
+ {{/each}}
266
+ {{/each}}
267
+ {{/if}}
268
+ {{#each commitGroups}}
269
+
270
+ {{#if title}}
271
+ ### {{title}}
272
+
273
+ {{/if}}
274
+ {{#each commits}}
275
+ {{> commit root=@root}}
276
+ {{/each}}
277
+ {{/each}}`.trim() + eol(2);
278
+ return new ConventionalChangelog().readPackage(`${pkgDir}/package.json`).config(preset).options({ releaseCount: 1 }).commits({ path: pkgDir }).tags({ prefix: tagPrefix });
279
+ };
280
+ const getChangelog = async ({ getPkgDir, tagPrefix }) => {
281
+ const generator = await createGenerator({
282
+ getPkgDir,
283
+ tagPrefix
284
+ });
285
+ let changelog = "";
286
+ for await (const chunk of generator.write()) changelog += chunk;
287
+ return changelog;
288
+ };
289
+ const generateChangelog = async ({ getPkgDir, tagPrefix }) => {
290
+ const infile = join(getPkgDir(), "CHANGELOG.md");
291
+ if (!existsSync(infile)) await writeFile(infile, "");
292
+ const originalChangelog = readFileSync(infile, "utf-8");
293
+ const generator = await createGenerator({
294
+ getPkgDir,
295
+ tagPrefix
296
+ });
297
+ const writeStream = createWriteStream(infile);
298
+ for await (const chunk of generator.write()) writeStream.write(chunk);
299
+ writeStream.write(originalChangelog);
300
+ writeStream.end();
301
+ await finished(writeStream);
302
+ };
303
+
304
+ //#endregion
305
+ //#region src/index.ts
306
+ const publishTagToNpm = async (options) => {
307
+ const { gitTag, defaultPackage, tagSeparator = "@", getPkgDir = () => ".", provenance } = options;
308
+ const tag = process.argv.slice(2)[0] || gitTag;
309
+ if (!tag) throw new Error(`❌ No git tag specified.`);
310
+ console.log(`\n📦 Start publishing from git tag: ${cyan(tag)}`);
311
+ let pkgName = defaultPackage;
312
+ let version;
313
+ if (tag.includes(tagSeparator)) {
314
+ [pkgName, version] = tag.split(tagSeparator);
315
+ console.log(`🔍 Parsed tag → package: "${yellow(pkgName)}", version: "${green(version)}"`);
316
+ } else {
317
+ version = tag;
318
+ console.log(`🔍 Parsed tag → version only: "${green(version)}"`);
319
+ }
320
+ if (version.startsWith("v")) {
321
+ version = version.slice(1);
322
+ console.log(`✂️ Normalized version (strip v): ${version}`);
323
+ }
324
+ if (!pkgName) throw new Error(`❌ Package name should be specified in tag "${tag}" when defaultPackage is not set`);
325
+ console.log(`📁 Resolving package info for "${magenta(pkgName)}"...`);
326
+ const { pkg, pkgDir } = getPackageInfo(pkgName, getPkgDir);
327
+ console.log(`📄 package.json → name: ${yellow(pkg.name)}, version: ${green(pkg.version)}`);
328
+ console.log(`📂 Package directory: ${underline(pkgDir)}`);
329
+ if (pkg.version !== version) throw new Error(`❌ Package version from tag "${version}" mismatches with current version "${pkg.version}"`);
330
+ const publishTag = await resolvePublishTag(pkg.name, version);
331
+ console.log(`🏷 Resolved npm dist-tag: "${blue(publishTag)}" (version ${green(version)})`);
332
+ console.log(`🚀 Publishing "${red`${pkg.name}@${version}`}" to npm...`);
333
+ await publishPackage({
334
+ tag: publishTag,
335
+ args: provenance ? ["--provenance"] : [],
336
+ cwd: pkgDir
337
+ });
338
+ console.log(`🎉 Successfully published ${red`${pkg.name}@${version}`} with dist-tag "${blue(publishTag)}"\n`);
339
+ };
340
+
341
+ //#endregion
342
+ export { createGenerator, defineConfig, generateChangelog, getChangelog, mergeConfig, publishTagToNpm };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peiyanlu/release",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Release project tool.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,15 +21,15 @@
21
21
  "dev": "tsdown -w",
22
22
  "build": "tsdown",
23
23
  "prepublish": "tsdown",
24
- "release": "tsdown && node index.js",
24
+ "release": "node index.js",
25
25
  "test": "vitest run",
26
26
  "test:cov": "vitest run --coverage"
27
27
  },
28
28
  "dependencies": {
29
29
  "@clack/prompts": "^0.11.0",
30
30
  "@octokit/rest": "^22.0.1",
31
- "@peiyanlu/cli-utils": "^0.0.6",
32
- "@peiyanlu/ts-utils": "^0.0.4",
31
+ "@peiyanlu/cli-utils": "^0.0.7",
32
+ "@peiyanlu/ts-utils": "^0.0.5",
33
33
  "ansis": "^4.2.0",
34
34
  "commander": "^14.0.2",
35
35
  "conventional-changelog": "^7.1.1",