changelog-machine 2.0.0 → 2.1.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
@@ -26,6 +26,7 @@ npx changelog-machine --bump # Update package metadata and release cha
26
26
  | --- | --- |
27
27
  | `--full` | Rebuild the entire changelog |
28
28
  | `--bump` | Infer and apply a version bump |
29
+ | `--bump-command <command>` | Override the metadata update command; requires `--bump` and `{version}` |
29
30
  | `--dry-run` | Preview without writing |
30
31
  | `--output <path>` | Set the changelog destination |
31
32
  | `--repo-url <url>` | Override repository links with an HTTPS URL |
@@ -110,15 +111,30 @@ The highest-impact commit since the latest release wins:
110
111
  | `fix:` or `perf:` | Patch |
111
112
  | Other types | None |
112
113
 
113
- - Updates `package.json`, an existing npm lockfile (versions 1–3), and the changelog with `## v<next-version>`. No Git tag is needed to prepare that section; the CLI does not create one.
114
- - Leaves dependency versions unchanged.
114
+ - Calculates the exact next version, then runs `npm version <next-version> --no-git-tag-version --ignore-scripts` by default. npm owns `package.json` and its lockfiles; changelog-machine does not parse or restrict lockfile formats. npm must be available on `PATH` unless a custom command is supplied.
115
+ - Writes the release changelog with `## v<next-version>` only after the command succeeds and `package.json` contains the planned version. No Git tag is needed to prepare that section.
116
+ - The default command disables lifecycle scripts and Git commit/tag creation.
115
117
  - Requires the latest tag to match the package version; repeated bumps fail.
116
118
  - Without tags, analyzes all commits against the current package version.
117
- - Prerelease promotion, Yarn/pnpm lockfiles, and workspaces are unsupported.
119
+ - Prerelease promotion and workspace version orchestration are unsupported. Non-npm metadata and lockfiles can be maintained by a custom command.
118
120
  - `--bump --full` rebuilds historical sections too. Without `--full`, older sections remain unchanged. `--output` also works with bumps; `--dry-run` previews without writing. No release-worthy commits means no file changes.
119
121
  - After a successful bump, commit and create the matching version tag before another incremental update or bump.
120
122
 
121
- Files are staged first and replaced atomically per file—not as a multi-file transaction.
123
+ ### Custom package managers
124
+
125
+ ```sh
126
+ changelog-machine --bump \
127
+ --bump-command='bun pm version {version} --no-git-tag-version --ignore-scripts'
128
+ ```
129
+
130
+ Use `{version}` for the exact calculated version, not a relative bump such as `patch`. Every occurrence is replaced. Commands run in the repository directory through the system shell, with output sent to stderr. Shell syntax and executable availability depend on your platform.
131
+
132
+ `--bump-command` is **trusted, arbitrary shell input**. You are responsible for making it update `package.json` and the relevant lockfiles without commits, tags, pushes, publishing, or lifecycle side effects. npm does not maintain Bun, pnpm, or Yarn lockfiles; use a command or wrapper script appropriate to your package manager. The tool verifies the resulting package version, but cannot verify arbitrary lockfile contents or prevent custom-command side effects.
133
+
134
+ `--dry-run` prints the planned command to stderr and changelog to stdout without executing the command. No release-worthy commits means no command execution.
135
+
136
+ Changelog destinations are validated and temporary files staged before executing the command. Command failures or version mismatches leave the existing changelog untouched. Metadata changes made by a command are **not rolled back**; inspect repository changes before retrying. A failure while replacing the staged changelog can also leave metadata updated without the changelog; updates are not a multi-file transaction.
137
+
122
138
 
123
139
  ## API
124
140
 
@@ -142,7 +158,7 @@ const contributors = await resolveContributors(
142
158
  const enriched = renderMarkdown(releases, { repositoryUrl, contributors });
143
159
  ```
144
160
 
145
- Also exports `parseCommit`, `recommendedBump`, `planVersion`, and TypeScript types.
161
+ Also exports `parseCommit`, `recommendedBump`, `planVersion`, and TypeScript types. `planVersion` is read-only: it calculates `current`, `next`, and `bump`; its `files` array is now empty because metadata updates are delegated to a command.
146
162
  Pass a boundary tag to `getReleases(cwd, tag)` for incremental history.
147
163
  The API does not write files. Explicit GitLab profiles can be supplied to the renderer.
148
164
 
@@ -2,4 +2,4 @@ export type FilePlan = {
2
2
  path: string;
3
3
  content: string;
4
4
  };
5
- export declare function apply(files: FilePlan[]): Promise<void>;
5
+ export declare function apply(files: FilePlan[], beforeReplace?: () => Promise<void>): Promise<void>;
package/lib/cli/files.js CHANGED
@@ -14,7 +14,7 @@ async function fileMode(path) {
14
14
  return undefined;
15
15
  }
16
16
  }
17
- export async function apply(files) {
17
+ export async function apply(files, beforeReplace) {
18
18
  const staged = [];
19
19
  try {
20
20
  for (const file of files) {
@@ -24,6 +24,8 @@ export async function apply(files) {
24
24
  staged.push({ path: file.path, temporary });
25
25
  await writeFile(temporary, file.content, { flag: 'wx', mode });
26
26
  }
27
+ // Validate and stage changelog destinations before running a mutating command.
28
+ await beforeReplace?.();
27
29
  for (const file of staged)
28
30
  await rename(file.temporary, file.path);
29
31
  }
package/lib/cli/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { parseChangelog } from '../changelog/update.js';
3
3
  import { getLatestReleaseTag, getReleases } from '../git/history.js';
4
+ import { defaultBumpCommand, formatBumpCommand, runBumpCommand, } from '../version/command.js';
4
5
  import { apply } from './files.js';
5
6
  import { parseFlags, showInformation } from './options.js';
6
7
  import { changelogPath, planBump, planChangelog } from './plans.js';
@@ -15,34 +16,57 @@ async function readChangelog(path) {
15
16
  throw error;
16
17
  }
17
18
  }
18
- export async function run(args = process.argv.slice(2)) {
19
- const flags = parseFlags(args);
20
- if (await showInformation(flags))
21
- return;
22
- const cwd = process.cwd();
19
+ async function readExistingChangelog(flags, cwd) {
23
20
  const existing = flags.full
24
21
  ? undefined
25
22
  : await readChangelog(changelogPath(flags, cwd));
23
+ // Only regenerate history newer than the latest documented release.
26
24
  const boundary = existing === undefined ? undefined : parseChangelog(existing).boundaryTag;
27
- const bumpTag = flags.bump ? await getLatestReleaseTag(cwd) : undefined;
28
- const bump = flags.bump
29
- ? await planBump(await getReleases(cwd, bumpTag), cwd, bumpTag)
30
- : undefined;
31
- if (flags.bump && !bump?.next)
32
- return;
33
- let releases = await withProgress('Reading Git releases', () => getReleases(cwd, boundary));
25
+ return { existing, boundary };
26
+ }
27
+ async function prepareBump(flags, cwd) {
28
+ if (!flags.bump)
29
+ return undefined;
30
+ const tag = await getLatestReleaseTag(cwd);
31
+ return planBump(await getReleases(cwd, tag), cwd, tag);
32
+ }
33
+ function promoteUnreleased(releases, next, boundary) {
34
+ if (!next)
35
+ return releases;
36
+ // Incremental history may expose the previous tag only through Unreleased.
34
37
  const latestTag = releases.find((release) => release.tag !== 'Unreleased')?.tag ??
35
38
  releases.find((release) => release.tag === 'Unreleased')?.previousTag ??
36
39
  boundary;
37
- if (bump?.next) {
38
- releases = releases.map((release) => release.tag === 'Unreleased'
39
- ? { ...release, tag: `v${bump.next}`, previousTag: latestTag }
40
- : release);
41
- }
42
- const files = [
40
+ return releases.map((release) => release.tag === 'Unreleased'
41
+ ? { ...release, tag: `v${next}`, previousTag: latestTag }
42
+ : release);
43
+ }
44
+ async function prepareFiles(flags, cwd, existing, boundary, bump) {
45
+ const releases = await withProgress('Reading Git releases', () => getReleases(cwd, boundary));
46
+ return [
43
47
  ...(bump?.files ?? []),
44
- ...(await planChangelog(releases, flags, cwd, existing)),
48
+ ...(await planChangelog(promoteUnreleased(releases, bump?.next, boundary), flags, cwd, existing)),
45
49
  ];
46
- if (!flags['dry-run'])
47
- await apply(files);
50
+ }
51
+ async function applyOrPreview(files, flags, cwd, next) {
52
+ if (flags['dry-run']) {
53
+ if (next) {
54
+ console.error(`Would run: ${formatBumpCommand(flags['bump-command'] ?? defaultBumpCommand, next)}`);
55
+ }
56
+ return;
57
+ }
58
+ // Stage the changelog first; update metadata before replacing it.
59
+ await apply(files, next ? () => runBumpCommand(next, cwd, flags['bump-command']) : undefined);
60
+ }
61
+ export async function run(args = process.argv.slice(2)) {
62
+ const flags = parseFlags(args);
63
+ if (await showInformation(flags))
64
+ return;
65
+ const cwd = process.cwd();
66
+ const { existing, boundary } = await readExistingChangelog(flags, cwd);
67
+ const bump = await prepareBump(flags, cwd);
68
+ if (flags.bump && !bump?.next)
69
+ return;
70
+ const files = await prepareFiles(flags, cwd, existing, boundary, bump);
71
+ await applyOrPreview(files, flags, cwd, bump?.next);
48
72
  }
@@ -2,6 +2,7 @@ export type Flags = ReturnType<typeof parseFlags>;
2
2
  export declare function parseFlags(args: string[]): {
3
3
  full?: boolean | undefined;
4
4
  bump?: boolean | undefined;
5
+ 'bump-command'?: string | undefined;
5
6
  'dry-run'?: boolean | undefined;
6
7
  output?: string | undefined;
7
8
  'repo-url'?: string | undefined;
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { parseArgs } from 'node:util';
3
+ import { validateBumpCommand } from '../version/command.js';
3
4
  const help = `Usage: changelog-machine [options]
4
5
 
5
6
  Updates missing releases and refreshes Unreleased in CHANGELOG.md by default.
@@ -7,6 +8,7 @@ Creates a complete changelog when the file does not exist.
7
8
 
8
9
  --full Replace the entire CHANGELOG.md from Git history
9
10
  --bump Infer version; update package metadata and changelog
11
+ --bump-command <command> Trusted shell command containing {version} (requires --bump)
10
12
  --dry-run Preview without writing any files
11
13
  --output <path> Changelog destination (default: CHANGELOG.md)
12
14
  --repo-url <url> Repository URL for links
@@ -18,11 +20,12 @@ Creates a complete changelog when the file does not exist.
18
20
  Requires Node 24+ and Git. Never fetches, commits, tags, pushes or publishes.
19
21
  `;
20
22
  export function parseFlags(args) {
21
- return parseArgs({
23
+ const flags = parseArgs({
22
24
  args,
23
25
  options: {
24
26
  full: { type: 'boolean' },
25
27
  bump: { type: 'boolean' },
28
+ 'bump-command': { type: 'string' },
26
29
  'dry-run': { type: 'boolean' },
27
30
  output: { type: 'string' },
28
31
  'repo-url': { type: 'string' },
@@ -33,6 +36,12 @@ export function parseFlags(args) {
33
36
  },
34
37
  allowPositionals: false,
35
38
  }).values;
39
+ if (flags['bump-command'] !== undefined) {
40
+ if (!flags.bump)
41
+ throw new Error('--bump-command requires --bump');
42
+ validateBumpCommand(flags['bump-command']);
43
+ }
44
+ return flags;
36
45
  }
37
46
  export async function showInformation(flags) {
38
47
  if (flags.help) {
@@ -0,0 +1,5 @@
1
+ export declare const defaultBumpCommand = "npm version {version} --no-git-tag-version --ignore-scripts";
2
+ export declare function validateBumpCommand(command: string): void;
3
+ export declare function formatBumpCommand(command: string, version: string): string;
4
+ /** Custom commands are explicitly trusted shell input, never read from Git. */
5
+ export declare function runBumpCommand(version: string, cwd: string, command?: string): Promise<void>;
@@ -0,0 +1,52 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+ import { parseObject } from './json.js';
5
+ export const defaultBumpCommand = 'npm version {version} --no-git-tag-version --ignore-scripts';
6
+ export function validateBumpCommand(command) {
7
+ if (!command.trim() || !command.includes('{version}')) {
8
+ throw new Error('--bump-command must contain the {version} placeholder');
9
+ }
10
+ }
11
+ export function formatBumpCommand(command, version) {
12
+ validateBumpCommand(command);
13
+ if (!/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) {
14
+ throw new Error('Bump command requires a stable semver version');
15
+ }
16
+ return command.replaceAll('{version}', version);
17
+ }
18
+ /** Custom commands are explicitly trusted shell input, never read from Git. */
19
+ export async function runBumpCommand(version, cwd, command) {
20
+ const formatted = formatBumpCommand(command ?? defaultBumpCommand, version);
21
+ console.error(`Running bump command: ${formatted}`);
22
+ await new Promise((resolveCommand, reject) => {
23
+ // Avoid shell interpretation for the built-in command.
24
+ const child = command === undefined
25
+ ? spawn('npm', ['version', version, '--no-git-tag-version', '--ignore-scripts'], {
26
+ cwd,
27
+ stdio: ['ignore', 'pipe', 'pipe'],
28
+ shell: process.platform === 'win32',
29
+ })
30
+ : spawn(formatted, {
31
+ cwd,
32
+ shell: true,
33
+ stdio: ['ignore', 'pipe', 'pipe'],
34
+ });
35
+ // Keep stdout reserved for changelog previews.
36
+ child.stdout?.pipe(process.stderr, { end: false });
37
+ child.stderr?.pipe(process.stderr, { end: false });
38
+ child.on('error', (error) => {
39
+ reject(new Error(`Could not start bump command: ${error.message}. Check repository files before retrying.`, { cause: error }));
40
+ });
41
+ child.on('close', (code, signal) => {
42
+ if (code === 0)
43
+ resolveCommand();
44
+ else
45
+ reject(new Error(`Bump command failed (${signal ?? `exit ${code}`}). Repository files may have changed; inspect them before retrying.`));
46
+ });
47
+ });
48
+ const manifest = parseObject(await readFile(resolve(cwd, 'package.json'), 'utf8'), resolve(cwd, 'package.json'));
49
+ if (manifest.version !== version) {
50
+ throw new Error(`Bump command did not set package.json version to ${version} (found ${String(manifest.version)}). Repository files may have changed; inspect them before retrying.`);
51
+ }
52
+ }
@@ -1,8 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
3
  import { recommendedBump } from '../commits/parse.js';
4
- import { formatJson, parseObject } from './json.js';
5
- import { readLockfiles, updateLockfile } from './lockfiles.js';
4
+ import { parseObject } from './json.js';
6
5
  const stableVersionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
7
6
  const tagPrefixPattern = /^v/;
8
7
  function readCurrentVersion(manifest) {
@@ -37,15 +36,6 @@ export async function planVersion(commits, latestTag, cwd = process.cwd()) {
37
36
  if (!bump)
38
37
  return { current, files: [] };
39
38
  const next = bumpVersion(current, bump);
40
- const lockfiles = await readLockfiles(cwd);
41
- const files = lockfiles.map(({ path, content }) => ({
42
- path,
43
- content: updateLockfile(path, content, next),
44
- }));
45
- manifest.version = next;
46
- files.unshift({
47
- path: packagePath,
48
- content: formatJson(manifest, packageText),
49
- });
50
- return { current, next, bump, files };
39
+ // Metadata and lockfiles are owned by the configured package-manager command.
40
+ return { current, next, bump, files: [] };
51
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changelog-machine",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Minimal, dependency-free conventional changelog and version generator",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -49,4 +49,4 @@
49
49
  "@types/node": "^24.0.0",
50
50
  "typescript": "^7.0.2"
51
51
  }
52
- }
52
+ }
@@ -1,3 +0,0 @@
1
- import type { VersionPlan } from './plan.js';
2
- export declare function readLockfiles(cwd: string): Promise<VersionPlan['files']>;
3
- export declare function updateLockfile(path: string, content: string, next: string): string;
@@ -1,44 +0,0 @@
1
- import { resolve } from 'node:path';
2
- import { formatJson, isObject, parseObject, readOptionalFile } from './json.js';
3
- export async function readLockfiles(cwd) {
4
- const lockNames = [
5
- 'package-lock.json',
6
- 'npm-shrinkwrap.json',
7
- 'pnpm-lock.yaml',
8
- 'yarn.lock',
9
- ];
10
- const lockTexts = await Promise.all(lockNames.map((name) => readOptionalFile(resolve(cwd, name))));
11
- if (lockTexts[0] !== undefined && lockTexts[1] !== undefined) {
12
- throw new Error('Both package-lock.json and npm-shrinkwrap.json exist. Remove the unused lockfile to resolve ambiguity before bumping.');
13
- }
14
- const unsupported = lockNames.filter((_, index) => index >= 2 && lockTexts[index] !== undefined);
15
- if (unsupported.length) {
16
- throw new Error(`Unsupported lockfile: ${unsupported.join(', ')}. Update it with its package manager instead; only npm lockfiles are supported.`);
17
- }
18
- const files = [];
19
- for (let index = 0; index < 2; index++) {
20
- const content = lockTexts[index];
21
- if (content !== undefined)
22
- files.push({ path: resolve(cwd, lockNames[index]), content });
23
- }
24
- return files;
25
- }
26
- export function updateLockfile(path, content, next) {
27
- const lockfile = parseObject(content, path);
28
- if (![1, 2, 3].includes(lockfile.lockfileVersion)) {
29
- throw new Error(`Unsupported lockfileVersion in ${path}; expected npm lockfile version 1, 2, or 3.`);
30
- }
31
- if (typeof lockfile.version !== 'string') {
32
- throw new Error(`Malformed ${path}: missing root version. Regenerate the lockfile with npm.`);
33
- }
34
- if (lockfile.packages !== undefined || lockfile.lockfileVersion !== 1) {
35
- if (!isObject(lockfile.packages) ||
36
- !isObject(lockfile.packages['']) ||
37
- typeof lockfile.packages[''].version !== 'string') {
38
- throw new Error(`Malformed ${path}: missing packages[''].version. Regenerate the lockfile with npm.`);
39
- }
40
- lockfile.packages[''].version = next;
41
- }
42
- lockfile.version = next;
43
- return formatJson(lockfile, content);
44
- }