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/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
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "roots": ["/path/to/folder-of-repos"],
3
+ "packages": ["/path/to/a-single-repo"]
4
+ }
@@ -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
+ }
@@ -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
+ }