polyrepo-cli 1.0.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/src/repos.js ADDED
@@ -0,0 +1,114 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import pc from 'picocolors'
4
+ import { gitAsync } from './exec.js'
5
+ import { pMap } from './pMap.js'
6
+
7
+ export function isRepo(dirPath) {
8
+ return fs.existsSync(path.join(dirPath, 'package.json')) && fs.existsSync(path.join(dirPath, '.git'))
9
+ }
10
+
11
+ function toEntry(dirPath) {
12
+ return {
13
+ dir: path.basename(dirPath),
14
+ path: dirPath,
15
+ pkgPath: path.join(dirPath, 'package.json'),
16
+ }
17
+ }
18
+
19
+ // `config.roots` — folders whose direct subdirectories are packages (the
20
+ // original C:\work\NPM-style layout). `config.packages` — individual
21
+ // package folders given directly, for a one-off repo that doesn't live
22
+ // under any of the scanned roots. A folder missing package.json/.git is
23
+ // silently skipped when found via a root scan (e.g. vue-masonry-kit, which
24
+ // has only README/LICENSE so far) but reported when named explicitly in
25
+ // `packages`, since that's almost certainly a typo worth knowing about.
26
+ export function discoverRepos(config) {
27
+ const repos = []
28
+ const seen = new Set()
29
+
30
+ for (const root of config.roots) {
31
+ if (!fs.existsSync(root)) {
32
+ console.log(pc.yellow(`Configured root does not exist, skipping: ${root}`))
33
+ continue
34
+ }
35
+ const entries = fs.readdirSync(root, { withFileTypes: true })
36
+ for (const entry of entries) {
37
+ if (!entry.isDirectory()) continue
38
+ const repoPath = path.join(root, entry.name)
39
+ if (!isRepo(repoPath)) continue
40
+ addRepo(repoPath)
41
+ }
42
+ }
43
+
44
+ for (const pkgDir of config.packages) {
45
+ if (!fs.existsSync(pkgDir)) {
46
+ console.log(pc.yellow(`Configured package folder does not exist, skipping: ${pkgDir}`))
47
+ continue
48
+ }
49
+ if (!isRepo(pkgDir)) {
50
+ console.log(pc.yellow(`Configured package folder has no package.json/.git, skipping: ${pkgDir}`))
51
+ continue
52
+ }
53
+ addRepo(pkgDir)
54
+ }
55
+
56
+ function addRepo(repoPath) {
57
+ const key = path.resolve(repoPath).toLowerCase()
58
+ if (seen.has(key)) return
59
+ seen.add(key)
60
+ repos.push(toEntry(repoPath))
61
+ }
62
+
63
+ repos.sort((a, b) => a.dir.localeCompare(b.dir))
64
+ return repos
65
+ }
66
+
67
+ export function readPackageJson(repo) {
68
+ const text = fs.readFileSync(repo.pkgPath, 'utf8')
69
+ const nameMatch = text.match(/"name"\s*:\s*"([^"]+)"/)
70
+ const versionMatch = text.match(/"version"\s*:\s*"([^"]+)"/)
71
+ return {
72
+ text,
73
+ name: nameMatch ? nameMatch[1] : repo.dir,
74
+ version: versionMatch ? versionMatch[1] : null,
75
+ }
76
+ }
77
+
78
+ // Full snapshot used everywhere a package list is shown: name, version,
79
+ // current branch, and whether the working tree has uncommitted changes.
80
+ // The two git calls per repo run concurrently across repos (see pMap)
81
+ // instead of one repo waiting on the last.
82
+ export async function inspectRepoAsync(repo) {
83
+ const pkg = readPackageJson(repo)
84
+ const [branchResult, statusResult] = await Promise.all([
85
+ gitAsync(repo.path, ['branch', '--show-current']),
86
+ gitAsync(repo.path, ['status', '--porcelain']),
87
+ ])
88
+ const branch = branchResult.ok ? branchResult.stdout || null : null
89
+ const clean = statusResult.ok && statusResult.stdout === ''
90
+ return { ...repo, ...pkg, branch, clean }
91
+ }
92
+
93
+ export function inspectRepos(repos, concurrency) {
94
+ return pMap(repos, inspectRepoAsync, concurrency)
95
+ }
96
+
97
+ // Raw dependency ranges declared by a package — used to cross-check whether
98
+ // one local package still points at a stale version of another local one
99
+ // after a bump. Parses the real JSON (unlike readPackageJson's regex-based
100
+ // extraction, which only needs name/version and deliberately avoids
101
+ // reformatting the file on write) since dependency objects are more than a
102
+ // single scalar to pull out reliably with a regex.
103
+ export function readPackageDependencies(repo) {
104
+ try {
105
+ const json = JSON.parse(fs.readFileSync(repo.pkgPath, 'utf8'))
106
+ return {
107
+ ...json.dependencies,
108
+ ...json.devDependencies,
109
+ ...json.peerDependencies,
110
+ }
111
+ } catch {
112
+ return {}
113
+ }
114
+ }
@@ -0,0 +1,38 @@
1
+ import { checkbox } from '@inquirer/prompts'
2
+ import pc from 'picocolors'
3
+ import { promptTheme } from './ui.js'
4
+ import { filterByNames } from './filterByNames.js'
5
+
6
+ // Shared "pick some packages" step used by both `bump` and `publish`:
7
+ // either takes an explicit --packages list (skips the prompt entirely,
8
+ // warning about any name that doesn't match a discovered package) or shows
9
+ // an aligned-column checkbox. `buildChoice` receives the full item list
10
+ // (so column widths can be computed across all of them) and must return a
11
+ // per-item `(item) => choice` function.
12
+ export async function selectPackages({ items, packages, message, buildChoice, pageSize = 20 }) {
13
+ if (packages) {
14
+ return filterByNames(items, packages)
15
+ }
16
+
17
+ const makeChoice = buildChoice(items)
18
+ const choices = items.map(makeChoice)
19
+
20
+ // @inquirer/checkbox throws (not rejects — a synchronous throw, so the
21
+ // top-level ExitPromptError handler in index.js never sees it) if every
22
+ // choice is disabled — e.g. `polyrepo release` before anything has been
23
+ // tagged yet. Callers that use `disabled` should really check for this
24
+ // themselves with a message specific to why (see release.js), but this
25
+ // is the backstop so a command that doesn't ends in a clean message
26
+ // instead of a raw ValidationError stack trace.
27
+ if (choices.length > 0 && choices.every((c) => c.disabled)) {
28
+ console.log(pc.yellow('Nothing selectable — every item is disabled.'))
29
+ return []
30
+ }
31
+
32
+ return checkbox({
33
+ message,
34
+ pageSize,
35
+ theme: promptTheme,
36
+ choices,
37
+ })
38
+ }
package/src/spinner.js ADDED
@@ -0,0 +1,38 @@
1
+ import pc from 'picocolors'
2
+
3
+ // Classic braille "dots" spinner (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) — same frames @inquirer/core
4
+ // uses by default for its own prompts. Visually a 2-column, 3-row dot grid
5
+ // with one dot missing at a time, circling around.
6
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
7
+ const INTERVAL_MS = 80
8
+ const CLEAR_LINE = '\r\x1b[K'
9
+
10
+ // Covers the "nothing is printed for several seconds" gap while a parallel
11
+ // batch (pMap) is in flight and there's no per-item progress to show yet —
12
+ // list's full mode, doctor, and the initial repo scan in bump/switch-master
13
+ // all used to just sit there silently. Falls back to a single static line
14
+ // when stdout isn't a real terminal (piped output, CI logs) — redrawing
15
+ // with carriage returns there would just dump a stream of raw \r bytes
16
+ // instead of animating.
17
+ export function startSpinner(message) {
18
+ if (!process.stdout.isTTY) {
19
+ console.log(pc.dim(message))
20
+ return { stop() {} }
21
+ }
22
+
23
+ let frame = 0
24
+ process.stdout.write(`${pc.yellow(FRAMES[0])} ${message}`)
25
+ const timer = setInterval(() => {
26
+ frame = (frame + 1) % FRAMES.length
27
+ process.stdout.write(`\r${pc.yellow(FRAMES[frame])} ${message}`)
28
+ }, INTERVAL_MS)
29
+
30
+ return {
31
+ stop() {
32
+ clearInterval(timer)
33
+ // Full line clear (not just enough spaces to cover the old text) —
34
+ // the final line's shape doesn't have to match the spinner line's.
35
+ process.stdout.write(`${CLEAR_LINE}${pc.dim(message)}\n`)
36
+ },
37
+ }
38
+ }
package/src/tags.js ADDED
@@ -0,0 +1,30 @@
1
+ import { git, gitAsync } from './exec.js'
2
+
3
+ export function tagName(version) {
4
+ return `v${version}`
5
+ }
6
+
7
+ export function tagExists(repo, tag) {
8
+ // Checks the remote, not just the local repo — the source of truth for
9
+ // "was this version already released" is origin, and a fresh clone
10
+ // wouldn't have any tags fetched locally yet.
11
+ const result = git(repo.path, ['ls-remote', '--exit-code', '--tags', 'origin', tag], { quiet: true })
12
+ return result.ok
13
+ }
14
+
15
+ // Same check, used where many repos are checked at once (see pMap) —
16
+ // `polyrepo tag`'s package list, for one.
17
+ export async function tagExistsAsync(repo, tag) {
18
+ const result = await gitAsync(repo.path, ['ls-remote', '--exit-code', '--tags', 'origin', tag])
19
+ return result.ok
20
+ }
21
+
22
+ export function createAndPushTag(repo, tag, { dryRun } = {}) {
23
+ if (!git(repo.path, ['tag', '-a', tag, '-m', tag], { mutating: true, dryRun }).ok) {
24
+ return { ok: false, message: `git tag ${tag} failed.` }
25
+ }
26
+ if (!git(repo.path, ['push', 'origin', tag], { mutating: true, dryRun }).ok) {
27
+ return { ok: false, message: `git push origin ${tag} failed.` }
28
+ }
29
+ return { ok: true }
30
+ }
package/src/ui.js ADDED
@@ -0,0 +1,94 @@
1
+ import pc from 'picocolors'
2
+
3
+ // Column width/row-formatting is shared by `printTable` (the `list` output)
4
+ // and by the checkbox choice labels in `bump`/`switch-master`/`publish` —
5
+ // so a package name column lines up the same way whether it's shown in a
6
+ // static table or as a pickable list, instead of each command reinventing
7
+ // its own ad-hoc padding (which is how columns used to drift out of line
8
+ // once package names of different lengths were mixed in).
9
+ export function columnWidths(rows, columns) {
10
+ return columns.map((col) =>
11
+ Math.max(col.label?.length ?? 0, ...rows.map((row) => String(col.value(row)).length)),
12
+ )
13
+ }
14
+
15
+ export function formatRow(row, columns, widths, separator = ' ') {
16
+ return columns
17
+ .map((col, i) => {
18
+ const raw = String(col.value(row))
19
+ const styled = col.style ? col.style(row, raw) : raw
20
+ const pad = widths[i] - raw.length
21
+ return styled + ' '.repeat(Math.max(pad, 0))
22
+ })
23
+ .join(separator)
24
+ }
25
+
26
+ // Minimal aligned-column table printer — no extra dependency needed for
27
+ // the handful of columns this CLI ever shows.
28
+ //
29
+ // `groupBy`, when given, inserts a blank line wherever its value changes
30
+ // between consecutive rows — used by `outdated`/`prs`, where several rows
31
+ // belong to the same package, to visually separate one package's rows from
32
+ // the next instead of everything running together. Rows are expected to
33
+ // already be grouped (i.e. sorted by whatever `groupBy` returns) — this
34
+ // only looks at adjacent rows, it doesn't sort.
35
+ export function printTable(rows, columns, { groupBy } = {}) {
36
+ const widths = columnWidths(rows, columns)
37
+ const header = columns.map((col, i) => (col.label ?? '').padEnd(widths[i])).join(' ')
38
+ console.log('')
39
+ console.log(pc.bold(header))
40
+ console.log(pc.dim(widths.map((w) => '-'.repeat(w)).join(' ')))
41
+ let lastGroup
42
+ rows.forEach((row, i) => {
43
+ if (groupBy) {
44
+ const group = groupBy(row)
45
+ if (i > 0 && group !== lastGroup) console.log('')
46
+ lastGroup = group
47
+ }
48
+ console.log(formatRow(row, columns, widths))
49
+ })
50
+ }
51
+
52
+ export function heading(text) {
53
+ console.log('')
54
+ console.log(pc.bold(pc.cyan(text)))
55
+ console.log('')
56
+ }
57
+
58
+ export function stepHeading(index, total, label) {
59
+ console.log('')
60
+ console.log(pc.bold(pc.blue(`[${index}/${total}] ${label}`)))
61
+ }
62
+
63
+ export function ok(text) {
64
+ console.log(pc.green(` ✓ ${text}`))
65
+ }
66
+
67
+ export function fail(text) {
68
+ console.log(pc.red(` ✗ ${text}`))
69
+ }
70
+
71
+ export function warn(text) {
72
+ console.log(pc.yellow(` ! ${text}`))
73
+ }
74
+
75
+ // Shared theme for every `checkbox`/`select` prompt in this CLI:
76
+ //
77
+ // - `renderSelectedChoices` (checkbox only): the default collapses the
78
+ // picked items into one comma-joined line once you confirm — unreadable
79
+ // past a handful of packages. One per line instead.
80
+ // - `keysHelpTip`: both checkbox and select build their "↑↓ navigate •
81
+ // space select • ⏎ submit"-style footer by calling this with their own
82
+ // list of [key, action] pairs. Ctrl+C also cancels a prompt (it always
83
+ // did — see the top-level ExitPromptError handler in index.js, which is
84
+ // what stops that from crashing with a raw stack trace) but was never
85
+ // listed here, so it looked unsupported. Appending it — with the exact
86
+ // same bold-key/dim-action/dim-bullet styling @inquirer uses internally
87
+ // — makes the footer match what the prompt actually accepts.
88
+ export const promptTheme = {
89
+ style: {
90
+ renderSelectedChoices: (selectedChoices) => selectedChoices.map((choice) => `\n ${choice.name}`).join(''),
91
+ keysHelpTip: (keys) =>
92
+ [...keys, ['ctrl+c', 'cancel']].map(([key, action]) => `${pc.bold(key)} ${pc.dim(action)}`).join(pc.dim(' • ')),
93
+ },
94
+ }
package/src/version.js ADDED
@@ -0,0 +1,29 @@
1
+ // `type` is 'patch' (default), 'minor', or 'major' — a minor bump resets
2
+ // patch to 0, a major bump resets minor and patch to 0, matching semver's
3
+ // own rule for what "the next minor/major" means.
4
+ export function bumpVersion(version, type = 'patch') {
5
+ const match = version.match(/^(\d+)\.(\d+)\.(\d+)(.*)$/)
6
+ if (!match) throw new Error(`Cannot parse version: ${version}`)
7
+ const [, major, minor, patch, rest] = match
8
+ if (type === 'major') return `${Number(major) + 1}.0.0${rest}`
9
+ if (type === 'minor') return `${major}.${Number(minor) + 1}.0${rest}`
10
+ return `${major}.${minor}.${Number(patch) + 1}${rest}`
11
+ }
12
+
13
+ export function bumpPatch(version) {
14
+ return bumpVersion(version, 'patch')
15
+ }
16
+
17
+ // Text-level replace (not JSON.parse + stringify) so formatting, key
18
+ // order, and quote style in package.json are left untouched — only the
19
+ // first "version" field (the top-level one) changes.
20
+ export function replaceVersionInText(text, newVersion) {
21
+ let replaced = false
22
+ const updated = text.replace(/"version"\s*:\s*"([^"]+)"/, (match) => {
23
+ if (replaced) return match
24
+ replaced = true
25
+ return `"version": "${newVersion}"`
26
+ })
27
+ if (!replaced) throw new Error('No "version" field found in package.json')
28
+ return updated
29
+ }
@@ -0,0 +1,101 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { hasChangelog, addChangelogEntry, extractChangelogSection } from '../src/changelog.js'
7
+
8
+ function makeRepo(changelogContent) {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'polyrepo-changelog-test-'))
10
+ if (changelogContent !== undefined) {
11
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), changelogContent)
12
+ }
13
+ return { path: dir }
14
+ }
15
+
16
+ test('hasChangelog reflects whether CHANGELOG.md exists', () => {
17
+ assert.equal(hasChangelog(makeRepo('# Changelog\n')), true)
18
+ assert.equal(hasChangelog(makeRepo(undefined)), false)
19
+ })
20
+
21
+ test('addChangelogEntry inserts after "## [Unreleased]" when present', () => {
22
+ const repo = makeRepo(
23
+ ['# Changelog', '', '## [Unreleased]', '', '## [1.0.0] - 2026-01-01', '', '### Added', '', '- Initial release.', ''].join(
24
+ '\n',
25
+ ),
26
+ )
27
+
28
+ addChangelogEntry(repo, '1.0.1', ['abc123 fix: something real', 'def456 Merge pull request #1 from x/y'])
29
+
30
+ const text = fs.readFileSync(path.join(repo.path, 'CHANGELOG.md'), 'utf8')
31
+ const lines = text.split('\n')
32
+
33
+ const unreleasedIdx = lines.findIndex((l) => l.startsWith('## [Unreleased]'))
34
+ const newIdx = lines.findIndex((l) => l.startsWith('## [1.0.1]'))
35
+ const oldIdx = lines.findIndex((l) => l.startsWith('## [1.0.0]'))
36
+
37
+ assert.ok(unreleasedIdx < newIdx, 'new entry comes after Unreleased')
38
+ assert.ok(newIdx < oldIdx, 'new entry comes before the previous most-recent release')
39
+ assert.match(text, /- fix: something real/)
40
+ // Merge-commit noise must be filtered out of the draft.
41
+ assert.doesNotMatch(text, /Merge pull request/)
42
+ })
43
+
44
+ test('addChangelogEntry inserts before the first version heading when there is no Unreleased section', () => {
45
+ const repo = makeRepo(['# Changelog', '', '## [1.0.0] - 2026-01-01', '', '- Initial release.', ''].join('\n'))
46
+
47
+ addChangelogEntry(repo, '1.1.0', [])
48
+
49
+ const lines = fs.readFileSync(path.join(repo.path, 'CHANGELOG.md'), 'utf8').split('\n')
50
+ const newIdx = lines.findIndex((l) => l.startsWith('## [1.1.0]'))
51
+ const oldIdx = lines.findIndex((l) => l.startsWith('## [1.0.0]'))
52
+
53
+ assert.ok(newIdx !== -1 && oldIdx !== -1 && newIdx < oldIdx)
54
+ })
55
+
56
+ test('addChangelogEntry appends at the end when there are no version headings at all', () => {
57
+ const repo = makeRepo(['# Changelog', '', 'Nothing released yet.', ''].join('\n'))
58
+
59
+ addChangelogEntry(repo, '0.1.0', [])
60
+
61
+ const text = fs.readFileSync(path.join(repo.path, 'CHANGELOG.md'), 'utf8')
62
+ assert.match(text, /## \[0\.1\.0\]/)
63
+ })
64
+
65
+ test('addChangelogEntry omits the "### Changed" section when there are no real commits to list', () => {
66
+ const repo = makeRepo(['# Changelog', ''].join('\n'))
67
+
68
+ // Only a merge commit — filtered out, so nothing should end up under
69
+ // "### Changed" at all rather than an empty, pointless heading.
70
+ addChangelogEntry(repo, '0.1.0', ['abc Merge pull request #2 from x/y'])
71
+
72
+ const text = fs.readFileSync(path.join(repo.path, 'CHANGELOG.md'), 'utf8')
73
+ assert.match(text, /## \[0\.1\.0\]/)
74
+ assert.doesNotMatch(text, /### Changed/)
75
+ })
76
+
77
+ test('extractChangelogSection returns the body of one version, and null when missing', () => {
78
+ const repo = makeRepo(
79
+ [
80
+ '# Changelog',
81
+ '',
82
+ '## [1.2.0] - 2026-02-01',
83
+ '',
84
+ '### Fixed',
85
+ '',
86
+ '- A real bug.',
87
+ '',
88
+ '## [1.1.0] - 2026-01-01',
89
+ '',
90
+ '- Older stuff.',
91
+ '',
92
+ ].join('\n'),
93
+ )
94
+
95
+ const section = extractChangelogSection(repo, '1.2.0')
96
+ assert.match(section, /A real bug/)
97
+ assert.doesNotMatch(section, /Older stuff/)
98
+
99
+ assert.equal(extractChangelogSection(repo, '9.9.9'), null)
100
+ assert.equal(extractChangelogSection(makeRepo(undefined), '1.0.0'), null)
101
+ })
@@ -0,0 +1,51 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { findStaleLocalDeps } from '../src/crossDeps.js'
7
+
8
+ function makeRepo(name, version, dependencies) {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'polyrepo-crossdeps-test-'))
10
+ const pkgPath = path.join(dir, 'package.json')
11
+ fs.writeFileSync(pkgPath, JSON.stringify({ name, version, dependencies }, null, 2))
12
+ return { path: dir, pkgPath, name, version }
13
+ }
14
+
15
+ test('findStaleLocalDeps flags a dependency range that no longer matches', () => {
16
+ const dep = makeRepo('color-value-tools', '1.1.12')
17
+ const dependent = makeRepo('css-magic-gradient', '1.2.14', { 'color-value-tools': '^1.2.0' })
18
+
19
+ const issues = findStaleLocalDeps([dep, dependent])
20
+
21
+ assert.equal(issues.length, 1)
22
+ assert.equal(issues[0].repo.dir ?? issues[0].repo.name, dependent.name)
23
+ assert.equal(issues[0].depName, 'color-value-tools')
24
+ assert.equal(issues[0].localVersion, '1.1.12')
25
+ })
26
+
27
+ test('findStaleLocalDeps does not flag a range that still matches', () => {
28
+ const dep = makeRepo('color-value-tools', '1.1.12')
29
+ const dependent = makeRepo('css-magic-gradient', '1.2.14', { 'color-value-tools': '^1.1.6' })
30
+
31
+ assert.deepEqual(findStaleLocalDeps([dep, dependent]), [])
32
+ })
33
+
34
+ test('findStaleLocalDeps ignores dependencies on packages outside the given set', () => {
35
+ const dependent = makeRepo('a', '1.0.0', { vue: '^3.0.0', 'not-a-local-package': '^1.0.0' })
36
+ assert.deepEqual(findStaleLocalDeps([dependent]), [])
37
+ })
38
+
39
+ test('findStaleLocalDeps ignores non-semver ranges instead of throwing', () => {
40
+ const dep = makeRepo('a', '1.0.0')
41
+ const dependent = makeRepo('b', '1.0.0', { a: 'workspace:*' })
42
+ assert.deepEqual(findStaleLocalDeps([dep, dependent]), [])
43
+ })
44
+
45
+ test('findStaleLocalDeps does not flag a package against its own version field', () => {
46
+ // A package can't meaningfully depend on itself; readPackageDependencies
47
+ // would only ever see this via a name collision, but the repo === repo
48
+ // check should short-circuit before it matters either way.
49
+ const repo = makeRepo('a', '1.0.0', { a: '^2.0.0' })
50
+ assert.deepEqual(findStaleLocalDeps([repo]), [])
51
+ })
@@ -0,0 +1,56 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { bumpPatch, bumpVersion, replaceVersionInText } from '../src/version.js'
4
+
5
+ test('bumpVersion bumps minor and resets patch to 0', () => {
6
+ assert.equal(bumpVersion('1.2.9', 'minor'), '1.3.0')
7
+ assert.equal(bumpVersion('1.2.9-beta.1', 'minor'), '1.3.0-beta.1')
8
+ })
9
+
10
+ test('bumpVersion bumps major and resets minor/patch to 0', () => {
11
+ assert.equal(bumpVersion('1.2.9', 'major'), '2.0.0')
12
+ assert.equal(bumpVersion('1.2.9-beta.1', 'major'), '2.0.0-beta.1')
13
+ })
14
+
15
+ test('bumpVersion defaults to patch', () => {
16
+ assert.equal(bumpVersion('1.2.9'), '1.2.10')
17
+ })
18
+
19
+ test('bumpPatch increments the patch number', () => {
20
+ assert.equal(bumpPatch('1.2.9'), '1.2.10')
21
+ assert.equal(bumpPatch('0.0.0'), '0.0.1')
22
+ assert.equal(bumpPatch('10.20.9'), '10.20.10')
23
+ })
24
+
25
+ test('bumpPatch preserves a pre-release/build suffix', () => {
26
+ assert.equal(bumpPatch('0.2.7-beta.1'), '0.2.8-beta.1')
27
+ assert.equal(bumpPatch('1.0.0+build.5'), '1.0.1+build.5')
28
+ })
29
+
30
+ test('bumpPatch rejects a version it cannot parse', () => {
31
+ assert.throws(() => bumpPatch('not-a-version'), /Cannot parse version/)
32
+ assert.throws(() => bumpPatch('1.2'), /Cannot parse version/)
33
+ })
34
+
35
+ test('replaceVersionInText replaces only the first "version" field', () => {
36
+ const text = [
37
+ '{',
38
+ ' "name": "x",',
39
+ ' "version": "1.2.9",',
40
+ ' "nested": { "version": "9.9.9" }',
41
+ '}',
42
+ '',
43
+ ].join('\n')
44
+
45
+ const updated = replaceVersionInText(text, '1.2.10')
46
+
47
+ assert.match(updated, /"version": "1\.2\.10"/)
48
+ // The nested, unrelated "version" field must survive untouched.
49
+ assert.match(updated, /"version": "9\.9\.9"/)
50
+ // Nothing else about the file's formatting should move.
51
+ assert.equal(updated.split('\n').length, text.split('\n').length)
52
+ })
53
+
54
+ test('replaceVersionInText throws when there is no "version" field at all', () => {
55
+ assert.throws(() => replaceVersionInText('{ "name": "x" }', '1.0.0'), /No "version" field/)
56
+ })