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/LICENSE +21 -0
- package/README.md +686 -0
- package/package.json +42 -0
- package/polyrepo.config.example.json +4 -0
- package/src/changelog.js +81 -0
- package/src/changes.js +55 -0
- package/src/ciChecks.js +26 -0
- package/src/commands/bump.js +245 -0
- package/src/commands/clone.js +102 -0
- package/src/commands/doctor.js +100 -0
- package/src/commands/exec.js +76 -0
- package/src/commands/list.js +107 -0
- package/src/commands/outdated.js +92 -0
- package/src/commands/prs.js +65 -0
- package/src/commands/publish.js +82 -0
- package/src/commands/release.js +121 -0
- package/src/commands/setup.js +121 -0
- package/src/commands/switchMaster.js +76 -0
- package/src/commands/tag.js +120 -0
- package/src/config.js +5 -0
- package/src/configFile.js +37 -0
- package/src/crossDeps.js +31 -0
- package/src/exec.js +132 -0
- package/src/export.js +136 -0
- package/src/filterByNames.js +16 -0
- package/src/github.js +57 -0
- package/src/index.js +533 -0
- package/src/loadConfig.js +63 -0
- package/src/masterSync.js +18 -0
- package/src/pMap.js +20 -0
- package/src/registry.js +10 -0
- package/src/release.js +32 -0
- package/src/repos.js +114 -0
- package/src/selectPackages.js +38 -0
- package/src/spinner.js +38 -0
- package/src/tags.js +30 -0
- package/src/ui.js +94 -0
- package/src/version.js +29 -0
- package/test/changelog.test.js +101 -0
- package/test/crossDeps.test.js +51 -0
- package/test/version.test.js +56 -0
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "polyrepo-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Interactive CLI for managing a folder of local npm package repos: version bumps through a PR, npm publish, GitHub releases, and cross-package dependency drift checks — all from one tool.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"polyrepo": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=20"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "node --test"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"cli",
|
|
17
|
+
"polyrepo",
|
|
18
|
+
"monorepo",
|
|
19
|
+
"multi-repo",
|
|
20
|
+
"npm",
|
|
21
|
+
"release",
|
|
22
|
+
"version-bump",
|
|
23
|
+
"github-release",
|
|
24
|
+
"changelog"
|
|
25
|
+
],
|
|
26
|
+
"author": {
|
|
27
|
+
"name": "Danil Lisin Vladimirovich (Macrulez)",
|
|
28
|
+
"email": "macrulezru@gmail.com",
|
|
29
|
+
"url": "https://macrulez.ru/en"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/macrulezru/polyrepo-cli.git"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@inquirer/prompts": "^7.2.1",
|
|
38
|
+
"commander": "^12.1.0",
|
|
39
|
+
"picocolors": "^1.1.1",
|
|
40
|
+
"semver": "^7.8.5"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/changelog.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
export function changelogPath(repo) {
|
|
5
|
+
return path.join(repo.path, 'CHANGELOG.md')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function hasChangelog(repo) {
|
|
9
|
+
return fs.existsSync(changelogPath(repo))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function todayISO() {
|
|
13
|
+
return new Date().toISOString().slice(0, 10)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Inserts a "## [x.y.z] - YYYY-MM-DD" entry into an existing Keep a
|
|
17
|
+
// Changelog-style CHANGELOG.md, as the new most-recent release:
|
|
18
|
+
// - right after "## [Unreleased]" if the file has one (so Unreleased
|
|
19
|
+
// stays in place, empty, at the top)
|
|
20
|
+
// - otherwise right before the first existing "## " version heading
|
|
21
|
+
// - otherwise (no version headings at all) appended at the end
|
|
22
|
+
// `commitLines` (from changes.js's fullCommitLinesSince) seeds a "###
|
|
23
|
+
// Changed" section as a draft — merge-commit noise filtered out, but this
|
|
24
|
+
// is a starting point to review and edit, not a finished changelog. Only
|
|
25
|
+
// ever called when hasChangelog() is already true — packages without one
|
|
26
|
+
// are left alone rather than having a CHANGELOG.md invented for them.
|
|
27
|
+
export function addChangelogEntry(repo, version, commitLines) {
|
|
28
|
+
const filePath = changelogPath(repo)
|
|
29
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n')
|
|
30
|
+
|
|
31
|
+
const bullets = commitLines
|
|
32
|
+
.map((l) => l.replace(/^[0-9a-f]+\s+/, '').trim())
|
|
33
|
+
.filter((l) => l && !/^Merge (pull request|branch)\b/i.test(l))
|
|
34
|
+
.map((l) => `- ${l}`)
|
|
35
|
+
|
|
36
|
+
const entry = [`## [${version}] - ${todayISO()}`, '']
|
|
37
|
+
if (bullets.length > 0) entry.push('### Changed', '', ...bullets, '')
|
|
38
|
+
|
|
39
|
+
const unreleasedIndex = lines.findIndex((l) => /^## \[Unreleased\]/i.test(l))
|
|
40
|
+
const searchStart = unreleasedIndex === -1 ? 0 : unreleasedIndex + 1
|
|
41
|
+
|
|
42
|
+
let insertAt = -1
|
|
43
|
+
for (let i = searchStart; i < lines.length; i++) {
|
|
44
|
+
if (/^## /.test(lines[i])) {
|
|
45
|
+
insertAt = i
|
|
46
|
+
break
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const before = insertAt === -1 ? lines : lines.slice(0, insertAt)
|
|
51
|
+
const after = insertAt === -1 ? [] : lines.slice(insertAt)
|
|
52
|
+
const updated = [...before, ...entry, ...after].join('\n')
|
|
53
|
+
|
|
54
|
+
fs.writeFileSync(filePath, updated.replace(/\n{3,}/g, '\n\n').trimEnd() + '\n')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Pulls just the body of one version's section back out — used by `polyrepo
|
|
58
|
+
// release` to seed GitHub Release notes from the changelog instead of
|
|
59
|
+
// gh's own --generate-notes when a CHANGELOG.md entry already exists for
|
|
60
|
+
// that version. Returns null if there's no changelog, no matching heading,
|
|
61
|
+
// or the section is empty.
|
|
62
|
+
export function extractChangelogSection(repo, version) {
|
|
63
|
+
if (!hasChangelog(repo)) return null
|
|
64
|
+
const lines = fs.readFileSync(changelogPath(repo), 'utf8').split('\n')
|
|
65
|
+
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
66
|
+
const headingIndex = lines.findIndex((l) => new RegExp(`^## \\[${escaped}\\]`).test(l))
|
|
67
|
+
if (headingIndex === -1) return null
|
|
68
|
+
|
|
69
|
+
let end = lines.length
|
|
70
|
+
for (let i = headingIndex + 1; i < lines.length; i++) {
|
|
71
|
+
if (/^## /.test(lines[i])) {
|
|
72
|
+
end = i
|
|
73
|
+
break
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const section = lines
|
|
77
|
+
.slice(headingIndex + 1, end)
|
|
78
|
+
.join('\n')
|
|
79
|
+
.trim()
|
|
80
|
+
return section || null
|
|
81
|
+
}
|
package/src/changes.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { MASTER_BRANCH } from './config.js'
|
|
2
|
+
import { git, gitAsync } from './exec.js'
|
|
3
|
+
import { pMap } from './pMap.js'
|
|
4
|
+
|
|
5
|
+
const PREVIEW_LIMIT = 5
|
|
6
|
+
|
|
7
|
+
// Summarizes what changed on local master since the last `v*` tag (or the
|
|
8
|
+
// last few commits if there's no tag yet), so the bump checklist can show
|
|
9
|
+
// whether a package actually has anything worth releasing. Reflects local
|
|
10
|
+
// repo state — run `polyrepo switch-master` first if it might be stale. Run
|
|
11
|
+
// through pMap across many repos at once (see describeRecentChangesForAll)
|
|
12
|
+
// — this builds the bump checkbox's preview, and waiting on 3 sequential
|
|
13
|
+
// git calls per repo, 17 times over, added up to a real pause before the
|
|
14
|
+
// prompt even appeared.
|
|
15
|
+
async function describeRecentChangesAsync(repo) {
|
|
16
|
+
const tagResult = await gitAsync(repo.path, ['describe', '--tags', '--abbrev=0', '--match', 'v*', MASTER_BRANCH])
|
|
17
|
+
const sinceTag = tagResult.ok ? tagResult.stdout : null
|
|
18
|
+
const range = sinceTag ? `${sinceTag}..${MASTER_BRANCH}` : MASTER_BRANCH
|
|
19
|
+
|
|
20
|
+
const [countResult, logResult] = await Promise.all([
|
|
21
|
+
gitAsync(repo.path, ['rev-list', '--count', range]),
|
|
22
|
+
gitAsync(repo.path, ['log', range, '--oneline', '--no-decorate', '-n', String(PREVIEW_LIMIT)]),
|
|
23
|
+
])
|
|
24
|
+
const count = countResult.ok ? Number(countResult.stdout) : 0
|
|
25
|
+
const lines = logResult.ok && logResult.stdout ? logResult.stdout.split('\n') : []
|
|
26
|
+
|
|
27
|
+
return { sinceTag, count, lines, truncated: count > lines.length }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function describeRecentChangesForAll(repos, concurrency) {
|
|
31
|
+
return pMap(repos, describeRecentChangesAsync, concurrency)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Uncapped commit list from the last `v*` tag to HEAD — used right before
|
|
35
|
+
// committing a version bump (see bump.js) to draft a CHANGELOG.md entry, so
|
|
36
|
+
// unlike describeRecentChanges (capped for a preview) this needs the whole
|
|
37
|
+
// list. Uses HEAD rather than MASTER_BRANCH because it's called from
|
|
38
|
+
// exactly where that matters: after checking out the bump branch, whose
|
|
39
|
+
// HEAD is master's tip at that point anyway.
|
|
40
|
+
export function fullCommitLinesSince(repo) {
|
|
41
|
+
const tagResult = git(repo.path, ['describe', '--tags', '--abbrev=0', '--match', 'v*', 'HEAD'], { quiet: true })
|
|
42
|
+
const sinceTag = tagResult.ok ? tagResult.stdout : null
|
|
43
|
+
const range = sinceTag ? `${sinceTag}..HEAD` : 'HEAD'
|
|
44
|
+
const logResult = git(repo.path, ['log', range, '--oneline', '--no-decorate'], { quiet: true })
|
|
45
|
+
return logResult.ok && logResult.stdout ? logResult.stdout.split('\n').filter(Boolean) : []
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatRecentChanges({ sinceTag, count, lines, truncated }) {
|
|
49
|
+
const header = sinceTag
|
|
50
|
+
? `${count} commit${count === 1 ? '' : 's'} since ${sinceTag}`
|
|
51
|
+
: `${count} commit${count === 1 ? '' : 's'} (no tags yet)`
|
|
52
|
+
if (count === 0) return header
|
|
53
|
+
const body = lines.map((l) => ` ${l}`).join('\n')
|
|
54
|
+
return `${header}:\n${body}${truncated ? '\n …' : ''}`
|
|
55
|
+
}
|
package/src/ciChecks.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { gh } from './exec.js'
|
|
2
|
+
|
|
3
|
+
// Blocks until a PR's CI checks finish, so `bump --wait-checks` doesn't
|
|
4
|
+
// merge on top of a red build. Two steps rather than one:
|
|
5
|
+
// 1. A quick, quiet probe (no --watch) to see whether the PR has any
|
|
6
|
+
// checks reported at all — most of these repos don't have CI wired up
|
|
7
|
+
// yet, and "no checks configured" isn't a reason to refuse merging.
|
|
8
|
+
// 2. Only if checks exist: the real wait, using gh's own `--watch`
|
|
9
|
+
// (rather than hand-rolled polling) with a real terminal so its live
|
|
10
|
+
// updating table actually renders instead of arriving all at once
|
|
11
|
+
// when it's done.
|
|
12
|
+
export function waitForChecks(repo, prNumber) {
|
|
13
|
+
const probe = gh(repo.path, ['pr', 'checks', String(prNumber), '--json', 'bucket'], { quiet: true })
|
|
14
|
+
if (!probe.ok || !probe.stdout || probe.stdout === '[]') {
|
|
15
|
+
return { ok: true, skipped: true }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const watched = gh(repo.path, ['pr', 'checks', String(prNumber), '--watch', '--interval', '10'], {
|
|
19
|
+
interactive: true,
|
|
20
|
+
})
|
|
21
|
+
if (watched.ok) return { ok: true, skipped: false }
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
message: `CI checks failed for PR #${prNumber} — not merging. Run \`gh pr checks ${prNumber}\` for details.`,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import { confirm } from '@inquirer/prompts'
|
|
3
|
+
import pc from 'picocolors'
|
|
4
|
+
import { discoverRepos, inspectRepos, readPackageJson } from '../repos.js'
|
|
5
|
+
import { MASTER_BRANCH, bumpBranchName } from '../config.js'
|
|
6
|
+
import { loadConfig } from '../loadConfig.js'
|
|
7
|
+
import { bumpVersion, replaceVersionInText } from '../version.js'
|
|
8
|
+
import { git } from '../exec.js'
|
|
9
|
+
import { syncMaster } from '../masterSync.js'
|
|
10
|
+
import { detectBumpState, createPr, mergePr } from '../github.js'
|
|
11
|
+
import { tagName, tagExists, createAndPushTag } from '../tags.js'
|
|
12
|
+
import { describeRecentChangesForAll, formatRecentChanges, fullCommitLinesSince } from '../changes.js'
|
|
13
|
+
import { hasChangelog, addChangelogEntry } from '../changelog.js'
|
|
14
|
+
import { waitForChecks } from '../ciChecks.js'
|
|
15
|
+
import { findStaleLocalDeps } from '../crossDeps.js'
|
|
16
|
+
import { heading, stepHeading, ok, fail, warn, columnWidths, formatRow } from '../ui.js'
|
|
17
|
+
import { selectPackages } from '../selectPackages.js'
|
|
18
|
+
import { startSpinner } from '../spinner.js'
|
|
19
|
+
|
|
20
|
+
export async function bumpCommand({
|
|
21
|
+
dryRun = false,
|
|
22
|
+
configPath,
|
|
23
|
+
packages, // string[] of dir names — skips the checkbox when given
|
|
24
|
+
yes = false, // skip the "proceed?" confirmation
|
|
25
|
+
waitChecks = false, // wait for CI checks (if any) before merging each PR
|
|
26
|
+
bumpType = 'patch', // 'patch' | 'minor' | 'major'
|
|
27
|
+
} = {}) {
|
|
28
|
+
const config = loadConfig({ configPath })
|
|
29
|
+
const discovered = discoverRepos(config)
|
|
30
|
+
if (discovered.length === 0) {
|
|
31
|
+
console.log(pc.yellow('No repos found.'))
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
heading(`Bump version (${bumpType})`)
|
|
36
|
+
|
|
37
|
+
const scanSpinner = startSpinner(`Checking ${discovered.length} package(s)...`)
|
|
38
|
+
const allRepos = await inspectRepos(discovered)
|
|
39
|
+
scanSpinner.stop()
|
|
40
|
+
|
|
41
|
+
const repos = allRepos.filter((r) => r.version)
|
|
42
|
+
if (repos.length === 0) {
|
|
43
|
+
console.log(pc.yellow('No repos found.'))
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const withTarget = repos.map((r) => ({ ...r, newVersion: bumpVersion(r.version, bumpType) }))
|
|
48
|
+
|
|
49
|
+
// Only worth computing when the checkbox is actually going to be shown —
|
|
50
|
+
// a --packages run never displays it, so skip the (parallel, but still
|
|
51
|
+
// real) work of asking every repo for its git log.
|
|
52
|
+
let withPreview = withTarget
|
|
53
|
+
if (!packages) {
|
|
54
|
+
const previewSpinner = startSpinner(`Checking what changed since the last tag for ${withTarget.length} package(s)...`)
|
|
55
|
+
const changesList = await describeRecentChangesForAll(withTarget)
|
|
56
|
+
previewSpinner.stop()
|
|
57
|
+
withPreview = withTarget.map((r, i) => ({ ...r, changes: changesList[i] }))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const selected = await selectPackages({
|
|
61
|
+
items: withPreview,
|
|
62
|
+
packages,
|
|
63
|
+
message: `Pick packages to bump (${bumpType}):`,
|
|
64
|
+
buildChoice: (all) => {
|
|
65
|
+
const columns = [
|
|
66
|
+
{ value: (r) => r.dir },
|
|
67
|
+
{ value: (r) => r.version, style: (r, t) => pc.dim(t) },
|
|
68
|
+
{ value: () => '→', style: (r, t) => pc.dim(t) },
|
|
69
|
+
{ value: (r) => r.newVersion, style: (r, t) => pc.green(t) },
|
|
70
|
+
]
|
|
71
|
+
const widths = columnWidths(all, columns)
|
|
72
|
+
return (r) => ({
|
|
73
|
+
name: formatRow(r, columns, widths) + (r.clean ? '' : pc.red(' (dirty, will be skipped)')),
|
|
74
|
+
description: formatRecentChanges(r.changes),
|
|
75
|
+
value: r,
|
|
76
|
+
})
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
if (selected.length === 0) {
|
|
81
|
+
console.log(pc.dim('Nothing selected.'))
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!yes) {
|
|
86
|
+
const proceed = await confirm({
|
|
87
|
+
message: `Bump ${selected.length} package(s), open a PR, and merge each into ${MASTER_BRANCH}?${
|
|
88
|
+
dryRun ? ' (dry run — no changes will actually be pushed)' : ''
|
|
89
|
+
}`,
|
|
90
|
+
default: true,
|
|
91
|
+
})
|
|
92
|
+
if (!proceed) {
|
|
93
|
+
console.log(pc.dim('Cancelled.'))
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let index = 0
|
|
99
|
+
for (const repo of selected) {
|
|
100
|
+
index += 1
|
|
101
|
+
stepHeading(index, selected.length, `${repo.dir} ${repo.version} → ${repo.newVersion}`)
|
|
102
|
+
await bumpOne(repo, { dryRun, waitChecks })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!dryRun) await reportStaleLocalDeps(config)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function bumpOne(repo, { dryRun, waitChecks }) {
|
|
109
|
+
if (!repo.clean) {
|
|
110
|
+
warn('Working tree is dirty — skipping to avoid committing unrelated changes.')
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const syncResult = syncMaster(repo)
|
|
115
|
+
if (!syncResult.ok) return fail(syncResult.message)
|
|
116
|
+
ok(`${MASTER_BRANCH} is up to date.`)
|
|
117
|
+
|
|
118
|
+
const branchName = bumpBranchName(repo.newVersion)
|
|
119
|
+
const state = detectBumpState(repo, branchName)
|
|
120
|
+
|
|
121
|
+
if (dryRun) {
|
|
122
|
+
if (state.status === 'merged') {
|
|
123
|
+
ok(`[dry-run] Already merged as PR #${state.pr.number} — would only ensure the tag exists.`)
|
|
124
|
+
} else {
|
|
125
|
+
const verb = state.status === 'fresh' ? 'create' : 'reuse'
|
|
126
|
+
const prVerb = state.status === 'open' ? `merge existing PR #${state.pr.number}` : 'open + merge a PR'
|
|
127
|
+
const waitNote = waitChecks ? ', waiting for CI checks first' : ''
|
|
128
|
+
console.log(
|
|
129
|
+
pc.magenta(
|
|
130
|
+
` [dry-run] would ${verb} branch ${branchName}, ensure version ${repo.newVersion} (+ a CHANGELOG.md entry if one exists), commit/push if needed, then ${prVerb}${waitNote}, then tag ${tagName(repo.newVersion)}.`,
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let prNumber = state.status === 'merged' || state.status === 'open' ? state.pr.number : null
|
|
138
|
+
|
|
139
|
+
if (state.status !== 'merged') {
|
|
140
|
+
if (state.status === 'open') ok(`Found existing open PR #${state.pr.number} for ${branchName} — reusing it.`)
|
|
141
|
+
if (state.status === 'branch') ok(`Found existing branch ${branchName} on origin without a PR — reusing it.`)
|
|
142
|
+
|
|
143
|
+
const checkoutResult = checkoutBumpBranch(repo, branchName)
|
|
144
|
+
if (!checkoutResult.ok) return fail(`Could not check out branch ${branchName}.`)
|
|
145
|
+
ok(checkoutResult.reused ? `Reusing branch ${branchName}.` : `Created branch ${branchName}.`)
|
|
146
|
+
|
|
147
|
+
const onDiskVersion = readPackageJson(repo).version
|
|
148
|
+
if (onDiskVersion !== repo.newVersion) {
|
|
149
|
+
const text = fs.readFileSync(repo.pkgPath, 'utf8')
|
|
150
|
+
fs.writeFileSync(repo.pkgPath, replaceVersionInText(text, repo.newVersion))
|
|
151
|
+
if (!git(repo.path, ['add', 'package.json']).ok) return fail('git add failed.')
|
|
152
|
+
|
|
153
|
+
if (hasChangelog(repo)) {
|
|
154
|
+
addChangelogEntry(repo, repo.newVersion, fullCommitLinesSince(repo))
|
|
155
|
+
if (git(repo.path, ['add', 'CHANGELOG.md']).ok) {
|
|
156
|
+
ok('Drafted a CHANGELOG.md entry — review it before merging if you want it polished.')
|
|
157
|
+
} else {
|
|
158
|
+
warn('Wrote a CHANGELOG.md entry but `git add` failed — it will stay as an uncommitted local change.')
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!git(repo.path, ['commit', '-m', `chore: bump version to ${repo.newVersion}`]).ok) {
|
|
163
|
+
return fail('git commit failed.')
|
|
164
|
+
}
|
|
165
|
+
ok(`package.json version set to ${repo.newVersion} and committed.`)
|
|
166
|
+
} else {
|
|
167
|
+
ok(`package.json on ${branchName} is already at ${repo.newVersion} — nothing to commit.`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!git(repo.path, ['push', '-u', 'origin', branchName]).ok) return fail('git push failed.')
|
|
171
|
+
ok('Branch pushed (or already up to date on origin).')
|
|
172
|
+
|
|
173
|
+
if (state.status !== 'open') {
|
|
174
|
+
const prResult = createPr(repo, {
|
|
175
|
+
base: MASTER_BRANCH,
|
|
176
|
+
branch: branchName,
|
|
177
|
+
title: `chore: bump version to ${repo.newVersion}`,
|
|
178
|
+
body: `Bump version: ${repo.version} → ${repo.newVersion}.`,
|
|
179
|
+
})
|
|
180
|
+
if (!prResult.ok) return fail(prResult.message ?? 'gh pr create failed.')
|
|
181
|
+
prNumber = prResult.number
|
|
182
|
+
ok(`Opened PR #${prNumber}.`)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (waitChecks) {
|
|
186
|
+
const checksResult = waitForChecks(repo, prNumber)
|
|
187
|
+
if (!checksResult.ok) return fail(checksResult.message)
|
|
188
|
+
ok(checksResult.skipped ? 'No CI checks reported — nothing to wait for.' : 'CI checks passed.')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!mergePr(repo, prNumber).ok) {
|
|
192
|
+
return fail(`gh pr merge failed — PR #${prNumber} is still open, merge it manually.`)
|
|
193
|
+
}
|
|
194
|
+
ok(`Merged PR #${prNumber}.`)
|
|
195
|
+
|
|
196
|
+
const resyncResult = syncMaster(repo)
|
|
197
|
+
if (!resyncResult.ok) return fail(resyncResult.message)
|
|
198
|
+
ok(`Local ${MASTER_BRANCH} synced to origin at ${repo.newVersion}.`)
|
|
199
|
+
} else {
|
|
200
|
+
ok(`Already merged as PR #${state.pr.number} — ${MASTER_BRANCH} already has it.`)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const tag = tagName(repo.newVersion)
|
|
204
|
+
if (tagExists(repo, tag)) {
|
|
205
|
+
ok(`Tag ${tag} already exists on origin.`)
|
|
206
|
+
} else {
|
|
207
|
+
const tagResult = createAndPushTag(repo, tag)
|
|
208
|
+
if (!tagResult.ok) fail(tagResult.message)
|
|
209
|
+
else ok(`Tagged and pushed ${tag}.`)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Reuse a local branch left over from a previous attempt if there is one,
|
|
214
|
+
// otherwise track the remote branch if a previous attempt got as far as
|
|
215
|
+
// pushing it, otherwise create it fresh from the current (just-synced)
|
|
216
|
+
// master. Trying "reuse" first before falling back is what makes this safe
|
|
217
|
+
// to call again after any partial failure without any state bookkeeping.
|
|
218
|
+
function checkoutBumpBranch(repo, branchName) {
|
|
219
|
+
if (git(repo.path, ['checkout', branchName], { quiet: true }).ok) {
|
|
220
|
+
return { ok: true, reused: true }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
git(repo.path, ['fetch', 'origin', branchName], { quiet: true })
|
|
224
|
+
if (git(repo.path, ['checkout', '-b', branchName, `origin/${branchName}`], { quiet: true }).ok) {
|
|
225
|
+
return { ok: true, reused: true }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { ok: git(repo.path, ['checkout', '-b', branchName]).ok, reused: false }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// One last look across every package (not just the ones just bumped) once
|
|
232
|
+
// the run is done — a bump can leave some other local package's
|
|
233
|
+
// dependencies/peerDependencies/devDependencies pointing at a range that no
|
|
234
|
+
// longer matches, and nothing else would surface that.
|
|
235
|
+
async function reportStaleLocalDeps(config) {
|
|
236
|
+
const repos = (await inspectRepos(discoverRepos(config))).filter((r) => r.version)
|
|
237
|
+
const issues = findStaleLocalDeps(repos)
|
|
238
|
+
if (issues.length === 0) return
|
|
239
|
+
heading('Stale local dependency references')
|
|
240
|
+
for (const issue of issues) {
|
|
241
|
+
warn(
|
|
242
|
+
`${issue.repo.dir}: depends on "${issue.depName}" via "${issue.range}", which does not match the local version ${issue.localVersion}.`,
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { confirm } from '@inquirer/prompts'
|
|
3
|
+
import pc from 'picocolors'
|
|
4
|
+
import { discoverRepos } from '../repos.js'
|
|
5
|
+
import { loadConfig } from '../loadConfig.js'
|
|
6
|
+
import { gh, git } from '../exec.js'
|
|
7
|
+
import { selectPackages } from '../selectPackages.js'
|
|
8
|
+
import { heading, stepHeading, ok, fail, columnWidths, formatRow } from '../ui.js'
|
|
9
|
+
|
|
10
|
+
// Diffs a GitHub org/user's repo list against what's already present under
|
|
11
|
+
// one root directory, then clones whatever's missing. `--org` is taken
|
|
12
|
+
// per-invocation rather than stored in polyrepo.config.json — the config's
|
|
13
|
+
// roots/packages are about *where local repos live*, not which GitHub
|
|
14
|
+
// account they come from, and one root can plausibly mix repos from
|
|
15
|
+
// several accounts.
|
|
16
|
+
export async function cloneCommand({
|
|
17
|
+
configPath,
|
|
18
|
+
org,
|
|
19
|
+
root,
|
|
20
|
+
includeArchived = false,
|
|
21
|
+
packages,
|
|
22
|
+
yes = false,
|
|
23
|
+
dryRun = false,
|
|
24
|
+
} = {}) {
|
|
25
|
+
const config = loadConfig({ configPath })
|
|
26
|
+
const targetRoot = root ? path.resolve(root) : config.roots[0]
|
|
27
|
+
if (!targetRoot) {
|
|
28
|
+
console.log(pc.red('No root to clone into — pass --root, or run `polyrepo setup` to add one first.'))
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
heading(`Clone missing repos from ${org}`)
|
|
33
|
+
console.log(pc.dim(`Target root: ${targetRoot}`))
|
|
34
|
+
|
|
35
|
+
const listResult = gh('.', ['repo', 'list', org, '--limit', '200', '--json', 'name,url,isArchived'], {
|
|
36
|
+
quiet: true,
|
|
37
|
+
})
|
|
38
|
+
if (!listResult.ok) {
|
|
39
|
+
fail(`Could not list repos for "${org}" — is \`gh\` authenticated and the name correct?`)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let remoteRepos
|
|
44
|
+
try {
|
|
45
|
+
remoteRepos = JSON.parse(listResult.stdout)
|
|
46
|
+
} catch {
|
|
47
|
+
fail('Could not parse `gh repo list` output.')
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!includeArchived) remoteRepos = remoteRepos.filter((r) => !r.isArchived)
|
|
52
|
+
|
|
53
|
+
const existing = new Set(discoverRepos({ roots: [targetRoot], packages: [] }).map((r) => r.dir.toLowerCase()))
|
|
54
|
+
const missing = remoteRepos.filter((r) => !existing.has(r.name.toLowerCase()))
|
|
55
|
+
|
|
56
|
+
if (missing.length === 0) {
|
|
57
|
+
console.log(pc.green(`Nothing to clone — every repo in ${org} already exists under ${targetRoot}.`))
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const selected = await selectPackages({
|
|
62
|
+
items: missing.map((r) => ({ dir: r.name, url: r.url })),
|
|
63
|
+
packages,
|
|
64
|
+
message: `Pick repos to clone into ${targetRoot}:`,
|
|
65
|
+
buildChoice: (all) => {
|
|
66
|
+
const columns = [{ value: (r) => r.dir }]
|
|
67
|
+
const widths = columnWidths(all, columns)
|
|
68
|
+
return (r) => ({ name: formatRow(r, columns, widths), value: r, checked: true })
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
if (selected.length === 0) {
|
|
73
|
+
console.log(pc.dim('Nothing selected.'))
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!yes) {
|
|
78
|
+
const proceed = await confirm({
|
|
79
|
+
message: `Clone ${selected.length} repo(s) into ${targetRoot}?${dryRun ? ' (dry run — nothing will actually be cloned)' : ''}`,
|
|
80
|
+
default: true,
|
|
81
|
+
})
|
|
82
|
+
if (!proceed) {
|
|
83
|
+
console.log(pc.dim('Cancelled.'))
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let index = 0
|
|
89
|
+
for (const repo of selected) {
|
|
90
|
+
index += 1
|
|
91
|
+
stepHeading(index, selected.length, repo.dir)
|
|
92
|
+
const dest = path.join(targetRoot, repo.dir)
|
|
93
|
+
const result = git('.', ['clone', repo.url, dest], { mutating: true, dryRun })
|
|
94
|
+
if (!result.ok) fail(`git clone failed (exit ${result.status}).`)
|
|
95
|
+
else ok(dryRun ? `Would clone into ${dest}.` : `Cloned into ${dest}.`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!dryRun && !config.roots.some((r) => path.resolve(r).toLowerCase() === targetRoot.toLowerCase())) {
|
|
99
|
+
console.log('')
|
|
100
|
+
console.log(pc.yellow(`Tip: ${targetRoot} isn't in your config yet — run \`polyrepo setup\` to add it as a root.`))
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import pc from 'picocolors'
|
|
2
|
+
import { discoverRepos, inspectRepos } from '../repos.js'
|
|
3
|
+
import { loadConfig } from '../loadConfig.js'
|
|
4
|
+
import { run } from '../exec.js'
|
|
5
|
+
import { findStaleLocalDeps } from '../crossDeps.js'
|
|
6
|
+
import { heading, ok, fail, warn } from '../ui.js'
|
|
7
|
+
import { startSpinner } from '../spinner.js'
|
|
8
|
+
|
|
9
|
+
export async function doctorCommand({ configPath } = {}) {
|
|
10
|
+
heading('Environment')
|
|
11
|
+
checkNode()
|
|
12
|
+
checkGit()
|
|
13
|
+
checkGh()
|
|
14
|
+
checkNpm()
|
|
15
|
+
|
|
16
|
+
heading('Config')
|
|
17
|
+
const config = loadConfig({ configPath })
|
|
18
|
+
console.log(pc.dim(`Config file: ${config.configPath}`))
|
|
19
|
+
const discovered = discoverRepos(config)
|
|
20
|
+
const spinner = startSpinner(`Checking ${discovered.length} package(s)...`)
|
|
21
|
+
const repos = await inspectRepos(discovered)
|
|
22
|
+
spinner.stop()
|
|
23
|
+
if (repos.length === 0) {
|
|
24
|
+
fail('No packages discovered — check `polyrepo setup`.')
|
|
25
|
+
} else {
|
|
26
|
+
ok(`${repos.length} package(s) discovered.`)
|
|
27
|
+
const dirty = repos.filter((r) => !r.clean)
|
|
28
|
+
if (dirty.length > 0) {
|
|
29
|
+
warn(`${dirty.length} repo(s) have uncommitted changes: ${dirty.map((r) => r.dir).join(', ')}`)
|
|
30
|
+
}
|
|
31
|
+
const offMaster = repos.filter((r) => r.branch !== 'master')
|
|
32
|
+
if (offMaster.length > 0) {
|
|
33
|
+
warn(`${offMaster.length} repo(s) are not on master: ${offMaster.map((r) => r.dir).join(', ')}`)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
heading('Cross-package dependencies')
|
|
38
|
+
if (repos.length > 0) {
|
|
39
|
+
const issues = findStaleLocalDeps(repos)
|
|
40
|
+
if (issues.length === 0) {
|
|
41
|
+
ok('No stale local dependency references found.')
|
|
42
|
+
} else {
|
|
43
|
+
for (const issue of issues) {
|
|
44
|
+
warn(
|
|
45
|
+
`${issue.repo.dir}: depends on "${issue.depName}" via "${issue.range}", which does not match the local version ${issue.localVersion}.`,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
console.log(pc.dim('Skipped — no packages discovered.'))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function checkNode() {
|
|
55
|
+
const [major] = process.versions.node.split('.').map(Number)
|
|
56
|
+
if (major >= 20) {
|
|
57
|
+
ok(`Node.js ${process.version} (>= 20 required).`)
|
|
58
|
+
} else {
|
|
59
|
+
fail(`Node.js ${process.version} — this CLI needs 20 or newer.`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function checkGit() {
|
|
64
|
+
const result = run('.', 'git', ['--version'], { quiet: true })
|
|
65
|
+
if (result.ok) {
|
|
66
|
+
ok(`${result.stdout}.`)
|
|
67
|
+
} else {
|
|
68
|
+
fail('git not found in PATH — required for every command, including this one.')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function checkGh() {
|
|
73
|
+
const version = run('.', 'gh', ['--version'], { quiet: true })
|
|
74
|
+
if (!version.ok) {
|
|
75
|
+
fail('gh (GitHub CLI) not found in PATH — required for `bump`, `release`, and PR merges. https://cli.github.com/')
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
const versionLine = version.stdout.split('\n')[0]
|
|
79
|
+
const auth = run('.', 'gh', ['auth', 'status'], { quiet: true })
|
|
80
|
+
if (auth.ok) {
|
|
81
|
+
const who = (auth.stdout + auth.stderr).match(/Logged in to [^\s]+ account (\S+)/)
|
|
82
|
+
ok(`${versionLine}${who ? ` — authenticated as ${who[1]}` : ' — authenticated'}.`)
|
|
83
|
+
} else {
|
|
84
|
+
fail(`${versionLine} — not authenticated. Run \`gh auth login\`.`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function checkNpm() {
|
|
89
|
+
const version = run('.', 'npm', ['--version'], { quiet: true })
|
|
90
|
+
if (!version.ok) {
|
|
91
|
+
fail('npm not found in PATH — required only for `polyrepo publish`.')
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
const who = run('.', 'npm', ['whoami'], { quiet: true })
|
|
95
|
+
if (who.ok) {
|
|
96
|
+
ok(`npm ${version.stdout} — authenticated as ${who.stdout}.`)
|
|
97
|
+
} else {
|
|
98
|
+
warn(`npm ${version.stdout} — not authenticated (only needed for \`polyrepo publish\`). Run \`npm login\`.`)
|
|
99
|
+
}
|
|
100
|
+
}
|