@git.zone/cli 6.10.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@git.zone/cli",
3
3
  "private": false,
4
- "version": "6.10.0",
4
+ "version": "6.12.0",
5
5
  "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.",
6
6
  "main": "dist_ts/index.js",
7
7
  "typings": "dist_ts/index.d.ts",
@@ -48,7 +48,7 @@
48
48
  "dependencies": {
49
49
  "@aws-sdk/client-s3": "3.1095.0",
50
50
  "@git.zone/tsdoc": "^2.1.1",
51
- "@git.zone/tsdocker": "3.5.2",
51
+ "@git.zone/tsdocker": "3.5.6",
52
52
  "@git.zone/tspublish": "^1.14.0",
53
53
  "@push.rocks/commitinfo": "^1.0.13",
54
54
  "@push.rocks/early": "^4.0.4",
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
 
@@ -206,7 +249,7 @@ Release flags:
206
249
 
207
250
  ### Exact artifacts and release journals
208
251
 
209
- Journal schema 1 supports Git and public npm publication. Schema 2 adds Docker qualification, promotion, and cleanup evidence without changing the storage path or schema-1 parsing and resume behavior. Fresh and resumed npm publication requires a qualified exact `pnpm` version: `11.25.0`, `12.1.0`, `12.2.1`, `12.3.0`, or `12.3.4`. GitZone checks that version's pack/publish command contract; other versions and missing required capabilities fail before publication. Registries must be unique canonical credential-free HTTP(S) URLs, and `release.targets.npm.accessLevel` must be `public` so verification does not depend on publisher credentials.
252
+ Journal schema 1 supports Git and public npm publication. Schema 2 adds Docker qualification, promotion, and cleanup evidence without changing the storage path or schema-1 parsing and resume behavior. Fresh and resumed npm publication dynamically qualifies the active stable `pnpm` version; there is no version allowlist. GitZone reads `minimum-release-age` and `minimum-release-age-exclude` through that exact pnpm invocation in the release directory, so project configuration and global policy retain pnpm's normal precedence. It verifies the selected version's publication time through the configured registry unless the effective policy disables the age delay or excludes that version. Missing timestamps follow `minimum-release-age-ignore-missing-time`, including pnpm's default of `true`; malformed policy, metadata failures, immature versions, and prerelease versions stop qualification. GitZone also verifies the required pack/publish command contract and keeps every subsequent command bound to the qualified exact version. A new pnpm version with the same capabilities needs no GitZone update once it satisfies the policy. Registries must be unique canonical credential-free HTTP(S) URLs, and `release.targets.npm.accessLevel` must be `public` so verification does not depend on publisher credentials.
210
253
 
211
254
  GitZone keeps the selected pnpm version for help checks, packing, and publication
212
255
  through `pnpm with <version>`, including release recovery and resume. This avoids
@@ -214,7 +257,7 @@ mixing a launcher's help output with a project-selected package-manager version.
214
257
  Use a standalone pnpm installation; Corepack launchers cannot run `pnpm with`.
215
258
  The normal release-age policy also applies when pnpm provisions that version.
216
259
 
217
- The pnpm release tests keep deterministic contracts for every supported version
260
+ The pnpm release tests cover known command dialects and dynamic version qualification
218
261
  and qualify actual packaging and script-free tarball publication with the active
219
262
  version. Run `testUnit` with each maturity-eligible standalone version and a matching
220
263
  project pin in an isolated checkout. For a focused matrix, invoke the project-local
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@git.zone/cli',
6
- version: '6.10.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
+ };