@git.zone/cli 6.11.0 → 6.12.0

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
@@ -107,6 +107,12 @@ gitzone commit -ytbp
107
107
 
108
108
  # Show the resolved workflow without mutating anything
109
109
  gitzone commit --plan
110
+
111
+ # Supply a message and changelog entry without AI
112
+ gitzone commit -y --message 'fix(cache): expire stale entries' --changelog 'Expired entries are removed before lookup.'
113
+
114
+ # Preserve a multiline message and custom Pending Markdown
115
+ gitzone commit -y --message-file /tmp/commit.txt --changelog-file /tmp/pending.md
110
116
  ```
111
117
 
112
118
  The commit flow:
@@ -128,6 +134,43 @@ Commit flags:
128
134
  | `-p`, `--push` | Push after the source commit |
129
135
  | `-f`, `--format` | Run `gitzone format --write` before commit |
130
136
  | `--plan` | Show resolved workflow only |
137
+ | `-m`, `--message <text>` | Use a complete semantic commit message without AI |
138
+ | `--message-file <path>` | Read the complete message from a UTF-8 file |
139
+ | `--changelog <text>` | Supply custom Pending text with a manual message |
140
+ | `--changelog-file <path>` | Read custom Pending text from a UTF-8 file |
141
+
142
+ Manual messages start with `type(scope): description`; the scope is optional.
143
+ Conventional `!` subjects and `BREAKING CHANGE:` footers are supported. The entire
144
+ message, including its body, is passed literally to Git. File inputs preserve
145
+ multiline text; CRLF becomes LF and terminal newlines are normalized. Each input
146
+ is limited to 128 KiB. Use `--message` or `--message-file` once, and at most one
147
+ changelog option. Changelog overrides require a manual message.
148
+
149
+ Without a changelog override, GitZone derives the Pending entry from the semantic
150
+ subject and body. Custom text becomes an entry in the message's semantic bucket.
151
+ For complete Markdown, supply a Pending fragment such as:
152
+
153
+ ```markdown
154
+ ### Fixes
155
+
156
+ - Remove expired entries before lookup.
157
+ - Preserve entries whose validity has not expired.
158
+ ```
159
+
160
+ Fragments may contain `### Breaking Changes`, `### Features`, `### Fixes`,
161
+ `### Documentation`, and `### Maintenance`. Omit the document title, `## Pending`,
162
+ and version headings. GitZone merges entries into the existing Pending buckets
163
+ and preserves previous entries and release history. Pending headings continue to
164
+ determine the release bump; a breaking message requires a Breaking Changes entry,
165
+ and either a breaking message or new breaking changelog text requires the usual
166
+ interactive confirmation or `-y --allow-breaking`.
167
+
168
+ Manual mode replaces the AI analysis step with `manual`. It keeps the configured
169
+ format, test, build, staging and push behavior. `--plan` validates and displays the
170
+ message and changelog without mutation or AI access. A clean repository remains
171
+ unchanged. Input is validated before workflow steps run; it never falls back to AI
172
+ after an input error. Place input files outside the repository if they should not
173
+ be included by the existing stage-all workflow.
131
174
 
132
175
  `-r` is intentionally not part of commit anymore. Use `gitzone release`.
133
176
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@git.zone/cli',
6
- version: '6.11.0',
6
+ version: '6.12.0',
7
7
  description: 'A comprehensive CLI tool for enhancing and managing local development workflows with gitzone utilities, focusing on project setup, version control, code formatting, and template management.'
8
8
  }
@@ -1,11 +1,7 @@
1
1
  import * as plugins from "./plugins.js";
2
2
 
3
3
  export type TChangelogBucket =
4
- | "Breaking Changes"
5
- | "Features"
6
- | "Fixes"
7
- | "Documentation"
8
- | "Maintenance";
4
+ "Breaking Changes" | "Features" | "Fixes" | "Documentation" | "Maintenance";
9
5
 
10
6
  export interface IChangelogEntry {
11
7
  type: string;
@@ -25,7 +21,7 @@ interface IChangelogSection {
25
21
  end: number;
26
22
  }
27
23
 
28
- const bucketForCommitType = (commitType: string): TChangelogBucket => {
24
+ export const bucketForCommitType = (commitType: string): TChangelogBucket => {
29
25
  switch (commitType) {
30
26
  case "BREAKING CHANGE":
31
27
  return "Breaking Changes";
@@ -44,18 +40,30 @@ const readChangelog = async (filePath: string): Promise<string> => {
44
40
  if (!(await plugins.smartfs.file(filePath).exists())) {
45
41
  return "# Changelog\n\n";
46
42
  }
47
- return (await plugins.smartfs.file(filePath).encoding("utf8").read()) as string;
43
+ return (await plugins.smartfs
44
+ .file(filePath)
45
+ .encoding("utf8")
46
+ .read()) as string;
48
47
  };
49
48
 
50
- const writeChangelog = async (filePath: string, content: string): Promise<void> => {
51
- await plugins.smartfs.file(filePath).encoding("utf8").write(content.endsWith("\n") ? content : `${content}\n`);
49
+ const writeChangelog = async (
50
+ filePath: string,
51
+ content: string,
52
+ ): Promise<void> => {
53
+ await plugins.smartfs
54
+ .file(filePath)
55
+ .encoding("utf8")
56
+ .write(content.endsWith("\n") ? content : `${content}\n`);
52
57
  };
53
58
 
54
59
  const findPendingSection = (
55
60
  content: string,
56
61
  sectionName: string,
57
62
  ): IChangelogSection | null => {
58
- const headingRegex = new RegExp(`^##\\s+${sectionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
63
+ const headingRegex = new RegExp(
64
+ `^##\\s+${sectionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`,
65
+ "m",
66
+ );
59
67
  const match = headingRegex.exec(content);
60
68
  if (!match || match.index === undefined) {
61
69
  return null;
@@ -64,7 +72,9 @@ const findPendingSection = (
64
72
  const bodyStart = match.index + match[0].length;
65
73
  const rest = content.slice(bodyStart);
66
74
  const nextHeadingMatch = /^##\s+/m.exec(rest);
67
- const end = nextHeadingMatch ? bodyStart + nextHeadingMatch.index : content.length;
75
+ const end = nextHeadingMatch
76
+ ? bodyStart + nextHeadingMatch.index
77
+ : content.length;
68
78
  return { start: match.index, bodyStart, end };
69
79
  };
70
80
 
@@ -95,30 +105,89 @@ export const appendPendingChangelogEntry = async (
95
105
  sectionName: string,
96
106
  entry: IChangelogEntry,
97
107
  ): Promise<void> => {
98
- let content = await ensurePendingSection(filePath, sectionName);
99
- const pendingSection = findPendingSection(content, sectionName)!;
100
- let pendingBody = content.slice(pendingSection.bodyStart, pendingSection.end);
101
108
  const bucket = bucketForCommitType(entry.type);
102
- const bucketHeading = `### ${bucket}`;
103
-
104
- const entryLines = [`- ${entry.message}${entry.scope ? ` (${entry.scope})` : ""}`];
109
+ const entryLines = [
110
+ `- ${entry.message}${entry.scope ? ` (${entry.scope})` : ""}`,
111
+ ];
105
112
  for (const detail of entry.details || []) {
106
113
  entryLines.push(` - ${detail}`);
107
114
  }
108
115
  const renderedEntry = entryLines.join("\n");
116
+ await appendPendingChangelogMarkdown(
117
+ filePath,
118
+ sectionName,
119
+ `### ${bucket}\n\n${renderedEntry}`,
120
+ );
121
+ };
109
122
 
110
- const bucketRegex = new RegExp(`^###\\s+${bucket.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
111
- const bucketMatch = bucketRegex.exec(pendingBody);
112
- if (!bucketMatch || bucketMatch.index === undefined) {
113
- pendingBody = `${pendingBody.trimEnd()}\n\n${bucketHeading}\n\n${renderedEntry}\n`;
114
- } else {
115
- const bucketBodyStart = bucketMatch.index + bucketMatch[0].length;
116
- const afterBucket = pendingBody.slice(bucketBodyStart);
117
- const nextBucketMatch = /^###\s+/m.exec(afterBucket);
118
- const insertAt = nextBucketMatch ? bucketBodyStart + nextBucketMatch.index : pendingBody.length;
119
- const beforeInsert = pendingBody.slice(0, insertAt).trimEnd();
120
- const afterInsert = pendingBody.slice(insertAt).replace(/^\n+/, "");
121
- pendingBody = `${beforeInsert}\n${renderedEntry}\n\n${afterInsert}`;
123
+ /** A Pending fragment contains canonical buckets, never the document/version headings. */
124
+ export const validatePendingChangelogMarkdown = (markdown: string): void => {
125
+ const headings = [...markdown.matchAll(/^#{1,3}\s+.*$/gm)].map(
126
+ (match) => match[0],
127
+ );
128
+ const allowed = new Set(
129
+ [
130
+ "Breaking Changes",
131
+ "Features",
132
+ "Fixes",
133
+ "Documentation",
134
+ "Maintenance",
135
+ ].map((bucket) => `### ${bucket}`),
136
+ );
137
+ if (
138
+ !markdown.startsWith("### ") ||
139
+ headings.length === 0 ||
140
+ headings.some((heading) => !allowed.has(heading))
141
+ ) {
142
+ throw new Error(
143
+ "Changelog fragments must use canonical ### bucket headings, without title, Pending or version headings.",
144
+ );
145
+ }
146
+ for (const block of markdown.split(/^### .+\n?/m).slice(1)) {
147
+ if (!block.trim())
148
+ throw new Error("Changelog buckets must contain an entry.");
149
+ }
150
+ };
151
+
152
+ export const appendPendingChangelogMarkdown = async (
153
+ filePath: string,
154
+ sectionName: string,
155
+ markdown: string,
156
+ ): Promise<void> => {
157
+ validatePendingChangelogMarkdown(markdown);
158
+ let content = await ensurePendingSection(filePath, sectionName);
159
+ const pendingSection = findPendingSection(content, sectionName)!;
160
+ let pendingBody = content.slice(pendingSection.bodyStart, pendingSection.end);
161
+ const sections = [...markdown.matchAll(/^### (.+)\n/gm)];
162
+ for (let index = 0; index < sections.length; index++) {
163
+ const section = sections[index];
164
+ const bucket = section[1];
165
+ const bucketHeading = `### ${bucket}`;
166
+ const renderedEntry = markdown
167
+ .slice(
168
+ section.index! + section[0].length,
169
+ sections[index + 1]?.index ?? markdown.length,
170
+ )
171
+ .trim();
172
+
173
+ const bucketRegex = new RegExp(
174
+ `^###\\s+${bucket.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`,
175
+ "m",
176
+ );
177
+ const bucketMatch = bucketRegex.exec(pendingBody);
178
+ if (!bucketMatch || bucketMatch.index === undefined) {
179
+ pendingBody = `${pendingBody.trimEnd()}\n\n${bucketHeading}\n\n${renderedEntry}\n`;
180
+ } else {
181
+ const bucketBodyStart = bucketMatch.index + bucketMatch[0].length;
182
+ const afterBucket = pendingBody.slice(bucketBodyStart);
183
+ const nextBucketMatch = /^###\s+/m.exec(afterBucket);
184
+ const insertAt = nextBucketMatch
185
+ ? bucketBodyStart + nextBucketMatch.index
186
+ : pendingBody.length;
187
+ const beforeInsert = pendingBody.slice(0, insertAt).trimEnd();
188
+ const afterInsert = pendingBody.slice(insertAt).replace(/^\n+/, "");
189
+ pendingBody = `${beforeInsert}\n${renderedEntry}\n\n${afterInsert}`;
190
+ }
122
191
  }
123
192
 
124
193
  content = `${content.slice(0, pendingSection.bodyStart)}\n${pendingBody.trim()}\n\n${content.slice(pendingSection.end).replace(/^\n+/, "")}`;
@@ -140,7 +209,9 @@ export const readPendingChangelog = async (
140
209
  };
141
210
  };
142
211
 
143
- export const inferVersionTypeFromPending = (pendingBlock: string): "patch" | "minor" | "major" => {
212
+ export const inferVersionTypeFromPending = (
213
+ pendingBlock: string,
214
+ ): "patch" | "minor" | "major" => {
144
215
  if (/^###\s+Breaking Changes\s*$/m.test(pendingBlock)) {
145
216
  return "major";
146
217
  }
@@ -162,7 +233,9 @@ export const movePendingToVersion = async (
162
233
  if (!pendingSection) {
163
234
  throw new Error("No pending changelog entries. Nothing to release.");
164
235
  }
165
- const pendingBlock = content.slice(pendingSection.bodyStart, pendingSection.end).trim();
236
+ const pendingBlock = content
237
+ .slice(pendingSection.bodyStart, pendingSection.end)
238
+ .trim();
166
239
  if (!pendingBlock) {
167
240
  throw new Error("No pending changelog entries. Nothing to release.");
168
241
  }
@@ -171,7 +244,10 @@ export const movePendingToVersion = async (
171
244
  .replaceAll("{{version}}", version)
172
245
  .replaceAll("{{date}}", dateString);
173
246
  const beforePending = content.slice(0, pendingSection.start).trimEnd();
174
- const afterPending = content.slice(pendingSection.end).replace(/^\n+/, "").trimEnd();
247
+ const afterPending = content
248
+ .slice(pendingSection.end)
249
+ .replace(/^\n+/, "")
250
+ .trimEnd();
175
251
  content = [beforePending, renderedHeading, pendingBlock, afterPending]
176
252
  .filter((block) => block.length > 0)
177
253
  .join("\n\n");
@@ -24,6 +24,13 @@ interface ICliConfigSettings {
24
24
 
25
25
  type TArgSource = Record<string, any> & { _?: string[] };
26
26
 
27
+ const commitValueFlags = new Set([
28
+ "message",
29
+ "message-file",
30
+ "changelog",
31
+ "changelog-file",
32
+ ]);
33
+
27
34
  const booleanLongFlags = new Set([
28
35
  "agent",
29
36
  "allow-breaking",
@@ -65,10 +72,23 @@ const getArgValue = (argvArg: TArgSource, key: string): any => {
65
72
 
66
73
  const parseRawArgv = (argv: string[]): TArgSource => {
67
74
  const parsedArgv: TArgSource = { _: [] };
75
+ const setCommitValue = (key: string, value: string | undefined): void => {
76
+ if (Object.hasOwn(parsedArgv, key))
77
+ throw new Error(`--${key} may only be supplied once.`);
78
+ if (value === undefined || value.length === 0)
79
+ throw new Error(`--${key} requires a value.`);
80
+ parsedArgv[key] = value;
81
+ parsedArgv[camelCase(key)] = value;
82
+ };
68
83
 
69
84
  for (let i = 0; i < argv.length; i++) {
70
85
  const currentArg = argv[i];
71
86
 
87
+ if (currentArg === "--") {
88
+ parsedArgv._!.push(...argv.slice(i + 1));
89
+ break;
90
+ }
91
+
72
92
  if (currentArg.startsWith("--no-")) {
73
93
  const key = currentArg.slice(5);
74
94
  parsedArgv[key] = false;
@@ -80,9 +100,20 @@ const parseRawArgv = (argv: string[]): TArgSource => {
80
100
  const withoutPrefix = currentArg.slice(2);
81
101
  const equalsIndex = withoutPrefix.indexOf("=");
82
102
  const rawKey =
83
- equalsIndex === -1 ? withoutPrefix : withoutPrefix.slice(0, equalsIndex);
103
+ equalsIndex === -1
104
+ ? withoutPrefix
105
+ : withoutPrefix.slice(0, equalsIndex);
84
106
  const inlineValue =
85
107
  equalsIndex === -1 ? undefined : withoutPrefix.slice(equalsIndex + 1);
108
+ if (commitValueFlags.has(rawKey)) {
109
+ const next = argv[i + 1];
110
+ setCommitValue(
111
+ rawKey,
112
+ inlineValue ?? (next && !next.startsWith("-") ? next : undefined),
113
+ );
114
+ if (inlineValue === undefined) i++;
115
+ continue;
116
+ }
86
117
  if (inlineValue !== undefined) {
87
118
  parsedArgv[rawKey] = inlineValue;
88
119
  parsedArgv[camelCase(rawKey)] = inlineValue;
@@ -108,7 +139,19 @@ const parseRawArgv = (argv: string[]): TArgSource => {
108
139
  }
109
140
 
110
141
  if (currentArg.startsWith("-") && currentArg.length > 1) {
111
- for (const shortFlag of currentArg.slice(1).split("")) {
142
+ const flags = currentArg.slice(1);
143
+ for (let flagIndex = 0; flagIndex < flags.length; flagIndex++) {
144
+ const shortFlag = flags[flagIndex];
145
+ if (shortFlag === "m") {
146
+ const attached = flags.slice(flagIndex + 1);
147
+ const next = argv[i + 1];
148
+ setCommitValue(
149
+ "message",
150
+ attached || (next && !next.startsWith("-") ? next : undefined),
151
+ );
152
+ if (!attached) i++;
153
+ break;
154
+ }
112
155
  parsedArgv[shortFlag] = true;
113
156
  }
114
157
  continue;
@@ -1,4 +1,5 @@
1
1
  import { getCliConfigValue } from "./helpers.smartconfig.js";
2
+ import { getProcessUserArgv } from "./helpers.climode.js";
2
3
 
3
4
  export type TConfirmationMode = "prompt" | "auto" | "plan";
4
5
 
@@ -171,7 +172,7 @@ export interface IReleaseWorkflowConfig {
171
172
  export interface IResolvedCommitWorkflow {
172
173
  confirmation: TConfirmationMode;
173
174
  allowBreaking: boolean;
174
- steps: TCommitStep[];
175
+ steps: Array<TCommitStep | "manual">;
175
176
  staging: "all";
176
177
  testCommand: string;
177
178
  buildCommand: string;
@@ -479,7 +480,7 @@ const readCliWorkflowConfig = async (): Promise<ICliWorkflowConfig> => {
479
480
  };
480
481
 
481
482
  const getOrderedArgsAfterCommand = (commandName: string): string[] => {
482
- const rawArgs = process.argv.slice(2);
483
+ const rawArgs = getProcessUserArgv();
483
484
  const commandIndex = rawArgs.indexOf(commandName);
484
485
  if (commandIndex === -1) {
485
486
  return rawArgs;
@@ -489,15 +490,29 @@ const getOrderedArgsAfterCommand = (commandName: string): string[] => {
489
490
 
490
491
  const getOrderedShortFlags = (commandName: string): string[] => {
491
492
  const orderedFlags: string[] = [];
492
- for (const arg of getOrderedArgsAfterCommand(commandName)) {
493
+ const args = getOrderedArgsAfterCommand(commandName);
494
+ const valueFlags = new Set([
495
+ "--message",
496
+ "--message-file",
497
+ "--changelog",
498
+ "--changelog-file",
499
+ ]);
500
+ for (let index = 0; index < args.length; index++) {
501
+ const arg = args[index];
493
502
  if (arg === "--") {
494
503
  break;
495
504
  }
496
505
  if (arg.startsWith("--")) {
506
+ if (valueFlags.has(arg)) index++;
497
507
  continue;
498
508
  }
499
509
  if (arg.startsWith("-") && arg.length > 1) {
500
- orderedFlags.push(...arg.slice(1).split(""));
510
+ const flags = arg.slice(1);
511
+ const messageIndex = flags.indexOf("m");
512
+ orderedFlags.push(
513
+ ...(messageIndex < 0 ? flags : flags.slice(0, messageIndex)).split(""),
514
+ );
515
+ if (messageIndex === flags.length - 1) index++;
501
516
  }
502
517
  }
503
518
  return orderedFlags;
@@ -545,6 +560,9 @@ const normalizeCommitSteps = (rawSteps: TCommitStep[]): TCommitStep[] => {
545
560
  }
546
561
 
547
562
  const changelogIndex = prePushSteps.indexOf("changelog");
563
+ if (analyzeIndex > changelogIndex) {
564
+ throw new Error("Commit workflow requires analyze before changelog.");
565
+ }
548
566
  if (changelogIndex === -1 || changelogIndex > commitIndex) {
549
567
  throw new Error("Commit workflow requires changelog before commit.");
550
568
  }
@@ -633,7 +651,28 @@ const buildReleasePlan = (options: {
633
651
 
634
652
  export const resolveCommitWorkflow = async (
635
653
  argvArg: any,
654
+ inputMode: "ai" | "manual" = "ai",
636
655
  ): Promise<IResolvedCommitWorkflow> => {
656
+ for (const flag of [
657
+ "y",
658
+ "yes",
659
+ "allow-breaking",
660
+ "allowBreaking",
661
+ "plan",
662
+ "f",
663
+ "format",
664
+ "t",
665
+ "test",
666
+ "b",
667
+ "build",
668
+ "p",
669
+ "push",
670
+ ]) {
671
+ if (argvArg[flag] !== undefined && typeof argvArg[flag] !== "boolean")
672
+ throw new Error(
673
+ `Commit option ${flag} must be a bare Boolean flag without a value.`,
674
+ );
675
+ }
637
676
  const cliConfig = await readCliWorkflowConfig();
638
677
  const commitConfig = cliConfig.commit || {};
639
678
  const releaseFlagRequested = Boolean(argvArg.r || argvArg.release);
@@ -677,7 +716,9 @@ export const resolveCommitWorkflow = async (
677
716
  return {
678
717
  confirmation,
679
718
  allowBreaking: Boolean(argvArg["allow-breaking"] || argvArg.allowBreaking),
680
- steps: normalizeCommitSteps(rawSteps),
719
+ steps: normalizeCommitSteps(rawSteps).map((step) =>
720
+ inputMode === "manual" && step === "analyze" ? "manual" : step,
721
+ ),
681
722
  staging: commitConfig.staging || "all",
682
723
  testCommand: commitConfig.test?.command || "pnpm test",
683
724
  buildCommand: commitConfig.build?.command || "pnpm build",
@@ -0,0 +1,156 @@
1
+ import * as plugins from "./mod.plugins.js";
2
+ import {
3
+ bucketForCommitType,
4
+ inferVersionTypeFromPending,
5
+ validatePendingChangelogMarkdown,
6
+ } from "../helpers.changelog.js";
7
+
8
+ export interface IManualCommitInput {
9
+ message: string;
10
+ type: string;
11
+ scope: string;
12
+ summary: string;
13
+ changelog: string;
14
+ breaking: boolean;
15
+ }
16
+
17
+ const maximumInputBytes = 128 * 1024;
18
+ const keys = [
19
+ "message",
20
+ "message-file",
21
+ "changelog",
22
+ "changelog-file",
23
+ ] as const;
24
+ type TInputKey = (typeof keys)[number];
25
+ const readArg = (argv: Record<string, unknown>, key: TInputKey): unknown =>
26
+ argv[key] ??
27
+ argv[key.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase())];
28
+
29
+ export const hasManualCommitInput = (argv: Record<string, unknown>): boolean =>
30
+ keys.some((key) => readArg(argv, key) !== undefined);
31
+
32
+ const readInput = async (
33
+ value: unknown,
34
+ fromFile: boolean,
35
+ cwd: string,
36
+ ): Promise<string> => {
37
+ if (typeof value !== "string" || !value.trim() || value.includes("\0"))
38
+ throw new Error(
39
+ "Manual commit inputs require non-empty text or file paths.",
40
+ );
41
+ let text = value;
42
+ if (fromFile) {
43
+ const file = await plugins.fs.open(
44
+ plugins.path.resolve(cwd, value),
45
+ plugins.fsConstants.O_RDONLY | plugins.fsConstants.O_NONBLOCK,
46
+ );
47
+ try {
48
+ const stat = await file.stat();
49
+ if (!stat.isFile() || stat.size > maximumInputBytes)
50
+ throw new Error(
51
+ "Manual input files must be regular files of at most 128 KiB.",
52
+ );
53
+ const bytes = Buffer.alloc(maximumInputBytes + 1);
54
+ let length = 0;
55
+ while (length < bytes.length) {
56
+ const part = await file.read(
57
+ bytes,
58
+ length,
59
+ bytes.length - length,
60
+ null,
61
+ );
62
+ if (!part.bytesRead) break;
63
+ length += part.bytesRead;
64
+ }
65
+ if (length > maximumInputBytes)
66
+ throw new Error("Manual inputs must be at most 128 KiB.");
67
+ text = new TextDecoder("utf-8", { fatal: true }).decode(
68
+ bytes.subarray(0, length),
69
+ );
70
+ } finally {
71
+ await file.close();
72
+ }
73
+ }
74
+ text = text.replace(/\r\n/g, "\n").replace(/\n+$/, "");
75
+ if (
76
+ !text.trim() ||
77
+ Buffer.byteLength(text, "utf8") > maximumInputBytes ||
78
+ /[\u0000-\u0008\u000b-\u001f\u007f]/.test(text)
79
+ ) {
80
+ throw new Error(
81
+ "Manual inputs must contain non-empty text without control characters, at most 128 KiB.",
82
+ );
83
+ }
84
+ return text;
85
+ };
86
+
87
+ export const loadManualCommitInput = async (
88
+ argv: Record<string, unknown>,
89
+ cwd: string,
90
+ ): Promise<IManualCommitInput | undefined> => {
91
+ if (!hasManualCommitInput(argv)) return undefined;
92
+ const message = readArg(argv, "message");
93
+ const messageFile = readArg(argv, "message-file");
94
+ const changelog = readArg(argv, "changelog");
95
+ const changelogFile = readArg(argv, "changelog-file");
96
+ if ((message === undefined) === (messageFile === undefined))
97
+ throw new Error(
98
+ "Supply exactly one of --message or --message-file for a manual commit.",
99
+ );
100
+ if (changelog !== undefined && changelogFile !== undefined)
101
+ throw new Error("Supply only one of --changelog or --changelog-file.");
102
+ const fullMessage = await readInput(
103
+ message ?? messageFile,
104
+ messageFile !== undefined,
105
+ cwd,
106
+ );
107
+ const [subject, ...body] = fullMessage.split("\n");
108
+ const parsed =
109
+ /^(BREAKING CHANGE|[a-z][a-z0-9-]*)(?:\(([^():\r\n]+)\))?(!)?: (\S.*)$/.exec(
110
+ subject,
111
+ );
112
+ if (!parsed)
113
+ throw new Error(
114
+ "Manual messages must start with a semantic subject: type(scope): description, with optional scope and !.",
115
+ );
116
+ const [, originalType, scope = "", bang, summary] = parsed;
117
+ const messageBreaking =
118
+ originalType === "BREAKING CHANGE" ||
119
+ Boolean(bang) ||
120
+ /^BREAKING[ -]CHANGE:\s*\S/m.test(fullMessage);
121
+ const type = messageBreaking ? "BREAKING CHANGE" : originalType;
122
+ let markdown: string;
123
+ if (changelog !== undefined || changelogFile !== undefined) {
124
+ const text = (
125
+ await readInput(
126
+ changelog ?? changelogFile,
127
+ changelogFile !== undefined,
128
+ cwd,
129
+ )
130
+ ).trim();
131
+ if (/^#{1,3}\s/m.test(text)) {
132
+ markdown = text;
133
+ } else {
134
+ markdown = `### ${bucketForCommitType(type)}\n\n- ${text.replace(/\n/g, "\n ")}`;
135
+ }
136
+ } else {
137
+ const details = body
138
+ .filter((line) => line.trim())
139
+ .map((line) => ` - ${line.trim()}`);
140
+ markdown = `### ${bucketForCommitType(type)}\n\n- ${summary}${scope ? ` (${scope})` : ""}${details.length ? "\n" + details.join("\n") : ""}`;
141
+ }
142
+ validatePendingChangelogMarkdown(markdown);
143
+ const changelogBreaking = inferVersionTypeFromPending(markdown) === "major";
144
+ if (messageBreaking && !changelogBreaking)
145
+ throw new Error(
146
+ "A breaking commit requires a Breaking Changes changelog bucket.",
147
+ );
148
+ return {
149
+ message: fullMessage,
150
+ type,
151
+ scope,
152
+ summary,
153
+ changelog: markdown,
154
+ breaking: messageBreaking || changelogBreaking,
155
+ };
156
+ };