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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts'
|
|
2
|
+
import pc from 'picocolors'
|
|
3
|
+
import { discoverRepos, inspectRepos } from '../repos.js'
|
|
4
|
+
import { loadConfig } from '../loadConfig.js'
|
|
5
|
+
import { run } from '../exec.js'
|
|
6
|
+
import { selectPackages } from '../selectPackages.js'
|
|
7
|
+
import { heading, stepHeading, ok, fail, columnWidths, formatRow } from '../ui.js'
|
|
8
|
+
|
|
9
|
+
// Runs one package at a time (not in parallel, unlike the read-only checks
|
|
10
|
+
// elsewhere in this CLI) and always with a real terminal (stdio: 'inherit')
|
|
11
|
+
// — the whole point is running an arbitrary command, which might itself
|
|
12
|
+
// want a TTY (colored output, its own progress bar, an interactive
|
|
13
|
+
// prompt), and interleaved output from several packages running the same
|
|
14
|
+
// command at once would be unreadable anyway.
|
|
15
|
+
export async function execCommand({ configPath, packages, yes = false, bail = false, cmd } = {}) {
|
|
16
|
+
const config = loadConfig({ configPath })
|
|
17
|
+
const repos = await inspectRepos(discoverRepos(config))
|
|
18
|
+
if (repos.length === 0) {
|
|
19
|
+
console.log(pc.yellow('No repos found.'))
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const label = cmd.join(' ')
|
|
24
|
+
heading(`Run: ${label}`)
|
|
25
|
+
|
|
26
|
+
const selected = await selectPackages({
|
|
27
|
+
items: repos,
|
|
28
|
+
packages,
|
|
29
|
+
message: 'Pick packages to run the command in:',
|
|
30
|
+
buildChoice: (all) => {
|
|
31
|
+
const columns = [
|
|
32
|
+
{ value: (r) => r.dir },
|
|
33
|
+
{ value: (r) => r.version ?? '?', style: (r, t) => pc.dim(t) },
|
|
34
|
+
]
|
|
35
|
+
const widths = columnWidths(all, columns)
|
|
36
|
+
return (r) => ({ name: formatRow(r, columns, widths), value: r, checked: true })
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
if (selected.length === 0) {
|
|
41
|
+
console.log(pc.dim('Nothing selected.'))
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!yes) {
|
|
46
|
+
const proceed = await confirm({
|
|
47
|
+
message: `Run \`${label}\` in ${selected.length} package(s)?`,
|
|
48
|
+
default: true,
|
|
49
|
+
})
|
|
50
|
+
if (!proceed) {
|
|
51
|
+
console.log(pc.dim('Cancelled.'))
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const failed = []
|
|
57
|
+
let index = 0
|
|
58
|
+
for (const repo of selected) {
|
|
59
|
+
index += 1
|
|
60
|
+
stepHeading(index, selected.length, repo.dir)
|
|
61
|
+
const result = run(repo.path, cmd[0], cmd.slice(1), { interactive: true })
|
|
62
|
+
if (!result.ok) {
|
|
63
|
+
failed.push(repo.dir)
|
|
64
|
+
fail(`Exited with code ${result.status}.`)
|
|
65
|
+
if (bail) break
|
|
66
|
+
} else {
|
|
67
|
+
ok('Done.')
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (failed.length > 0) {
|
|
72
|
+
console.log('')
|
|
73
|
+
console.log(pc.red(`${failed.length}/${selected.length} package(s) failed: ${failed.join(', ')}`))
|
|
74
|
+
process.exitCode = 1
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import pc from 'picocolors'
|
|
2
|
+
import { discoverRepos, inspectRepos } from '../repos.js'
|
|
3
|
+
import { MASTER_BRANCH } from '../config.js'
|
|
4
|
+
import { loadConfig } from '../loadConfig.js'
|
|
5
|
+
import { tagName, tagExistsAsync } from '../tags.js'
|
|
6
|
+
import { releaseExistsAsync } from '../release.js'
|
|
7
|
+
import { fetchPublishedVersionAsync } from '../registry.js'
|
|
8
|
+
import { findStaleLocalDeps } from '../crossDeps.js'
|
|
9
|
+
import { pMap } from '../pMap.js'
|
|
10
|
+
import { printTable, heading, ok, fail } from '../ui.js'
|
|
11
|
+
import { startSpinner } from '../spinner.js'
|
|
12
|
+
import { EXPORT_FORMATS, exportTable, inferExportFormat, writeExport } from '../export.js'
|
|
13
|
+
|
|
14
|
+
export async function listCommand({ configPath, quick = false, showPath = false, format, output } = {}) {
|
|
15
|
+
if (format && !EXPORT_FORMATS.includes(format)) {
|
|
16
|
+
fail(`Unknown --format "${format}" — expected one of: ${EXPORT_FORMATS.join(', ')}.`)
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
if (format && !output) {
|
|
20
|
+
fail('--format only matters together with --output <path> — nothing to export it to.')
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const config = loadConfig({ configPath })
|
|
25
|
+
const repos = discoverRepos(config)
|
|
26
|
+
if (repos.length === 0) {
|
|
27
|
+
console.log(pc.yellow('No repos found.'))
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
heading(`Packages (${repos.length})`)
|
|
32
|
+
|
|
33
|
+
const gitSpinner = startSpinner(`Checking ${repos.length} package(s) — version, branch, git status...`)
|
|
34
|
+
const rows = await inspectRepos(repos)
|
|
35
|
+
gitSpinner.stop()
|
|
36
|
+
|
|
37
|
+
const columns = [
|
|
38
|
+
{ label: 'Package', value: (r) => r.dir },
|
|
39
|
+
...(showPath ? [{ label: 'Path', value: (r) => r.path, style: (r, text) => pc.dim(text) }] : []),
|
|
40
|
+
{ label: 'Version', value: (r) => r.version ?? '?' },
|
|
41
|
+
{
|
|
42
|
+
label: 'Branch',
|
|
43
|
+
value: (r) => r.branch ?? '(detached)',
|
|
44
|
+
style: (r, text) => (r.branch === MASTER_BRANCH ? pc.dim(text) : pc.yellow(text)),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
label: 'Git',
|
|
48
|
+
value: (r) => (r.clean ? 'clean' : 'dirty'),
|
|
49
|
+
style: (r, text) => (r.clean ? pc.dim(text) : pc.red(text)),
|
|
50
|
+
},
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
let finalRows = rows
|
|
54
|
+
|
|
55
|
+
if (!quick) {
|
|
56
|
+
// Cross-package dependency drift is cheap (no network — just the
|
|
57
|
+
// package.jsons already on disk) and doesn't vary per package the way
|
|
58
|
+
// tag/release/npm do, so it's computed once for everyone rather than
|
|
59
|
+
// inside the per-repo pMap below.
|
|
60
|
+
const staleByRepo = new Map()
|
|
61
|
+
for (const issue of findStaleLocalDeps(rows)) {
|
|
62
|
+
staleByRepo.set(issue.repo.dir, (staleByRepo.get(issue.repo.dir) ?? 0) + 1)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const registrySpinner = startSpinner(`Checking ${rows.length} package(s) for tags, releases, and the registry...`)
|
|
66
|
+
const withReleaseInfo = await pMap(rows, async (r) => {
|
|
67
|
+
const tag = tagName(r.version)
|
|
68
|
+
const [tagged, published] = await Promise.all([tagExistsAsync(r, tag), fetchPublishedVersionAsync(r)])
|
|
69
|
+
const released = tagged ? await releaseExistsAsync(r, tag) : false
|
|
70
|
+
return { ...r, tag, tagged, released, published, staleDeps: staleByRepo.get(r.dir) ?? 0 }
|
|
71
|
+
})
|
|
72
|
+
registrySpinner.stop()
|
|
73
|
+
|
|
74
|
+
columns.push(
|
|
75
|
+
{
|
|
76
|
+
label: 'Tag',
|
|
77
|
+
value: (r) => (r.tagged ? r.tag : '—'),
|
|
78
|
+
style: (r, text) => pc.dim(text),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
label: 'Release',
|
|
82
|
+
value: (r) => (!r.tagged ? '—' : r.released ? '✓' : '✗'),
|
|
83
|
+
style: (r, text) => (r.released ? pc.green(text) : pc.dim(text)),
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
label: 'npm',
|
|
87
|
+
value: (r) => (r.published == null ? 'unpublished' : r.published === r.version ? '✓' : r.published),
|
|
88
|
+
style: (r, text) => (r.published === r.version ? pc.green(text) : pc.yellow(text)),
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
label: 'Deps',
|
|
92
|
+
value: (r) => (r.staleDeps > 0 ? `⚠ ${r.staleDeps}` : '✓'),
|
|
93
|
+
style: (r, text) => (r.staleDeps > 0 ? pc.yellow(text) : pc.dim(text)),
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
finalRows = withReleaseInfo
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
printTable(finalRows, columns)
|
|
101
|
+
|
|
102
|
+
if (output) {
|
|
103
|
+
const resolvedFormat = format ?? inferExportFormat(output)
|
|
104
|
+
const savedPath = writeExport(output, exportTable(finalRows, columns, resolvedFormat))
|
|
105
|
+
ok(`Saved ${resolvedFormat.toUpperCase()} to ${savedPath}.`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import semver from 'semver'
|
|
2
|
+
import pc from 'picocolors'
|
|
3
|
+
import { discoverRepos, inspectRepos } from '../repos.js'
|
|
4
|
+
import { loadConfig } from '../loadConfig.js'
|
|
5
|
+
import { npmAsync } from '../exec.js'
|
|
6
|
+
import { pMap } from '../pMap.js'
|
|
7
|
+
import { heading, printTable } from '../ui.js'
|
|
8
|
+
import { startSpinner } from '../spinner.js'
|
|
9
|
+
import { filterByNames } from '../filterByNames.js'
|
|
10
|
+
|
|
11
|
+
// How far behind "latest" actually is — a major bump is a different kind
|
|
12
|
+
// of attention than a patch, and lumping them under one color hid that.
|
|
13
|
+
// premajor/preminor collapse into their stable counterpart; prepatch and
|
|
14
|
+
// plain prerelease both read as "patch-level" here — none of them warrant
|
|
15
|
+
// their own bucket for a dependency table. Returns null when either side
|
|
16
|
+
// isn't a parseable version (current can be missing entirely for a
|
|
17
|
+
// package that's declared but never installed) or they're already equal.
|
|
18
|
+
function diffSeverity(current, latest) {
|
|
19
|
+
if (!current || !latest || !semver.valid(current) || !semver.valid(latest)) return null
|
|
20
|
+
const diff = semver.diff(current, latest)
|
|
21
|
+
if (!diff) return null
|
|
22
|
+
if (diff === 'major' || diff === 'premajor') return 'major'
|
|
23
|
+
if (diff === 'minor' || diff === 'preminor') return 'minor'
|
|
24
|
+
return 'patch'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const SEVERITY_COLOR = { major: pc.red, minor: pc.yellow, patch: pc.dim }
|
|
28
|
+
|
|
29
|
+
export async function outdatedCommand({ configPath, packages } = {}) {
|
|
30
|
+
const config = loadConfig({ configPath })
|
|
31
|
+
const repos = filterByNames((await inspectRepos(discoverRepos(config))).filter((r) => r.version), packages)
|
|
32
|
+
if (repos.length === 0) {
|
|
33
|
+
console.log(pc.yellow('No repos found.'))
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
heading('Outdated dependencies')
|
|
38
|
+
|
|
39
|
+
const spinner = startSpinner(`Checking ${repos.length} package(s) for outdated dependencies...`)
|
|
40
|
+
const results = await pMap(repos, async (r) => {
|
|
41
|
+
// `npm outdated --json` exits 1 whenever it finds anything outdated —
|
|
42
|
+
// that's normal, not a failure, so "did this work" is judged by
|
|
43
|
+
// whether stdout is parseable JSON rather than the exit code.
|
|
44
|
+
const result = await npmAsync(r.path, ['outdated', '--json'])
|
|
45
|
+
if (!result.stdout) return { repo: r, entries: [], failed: !result.ok }
|
|
46
|
+
try {
|
|
47
|
+
return { repo: r, entries: Object.entries(JSON.parse(result.stdout)).map(([name, info]) => ({ name, ...info })) }
|
|
48
|
+
} catch {
|
|
49
|
+
return { repo: r, entries: [], failed: true }
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
spinner.stop()
|
|
53
|
+
|
|
54
|
+
const failedRepos = results.filter((r) => r.failed).map((r) => r.repo.dir)
|
|
55
|
+
if (failedRepos.length > 0) {
|
|
56
|
+
console.log(pc.yellow(`Could not check: ${failedRepos.join(', ')}`))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const rows = results.flatMap(({ repo, entries }) =>
|
|
60
|
+
entries.map((e) => ({
|
|
61
|
+
dir: repo.dir,
|
|
62
|
+
name: e.name,
|
|
63
|
+
current: e.current ?? '—',
|
|
64
|
+
wanted: e.wanted ?? '—',
|
|
65
|
+
latest: e.latest ?? '—',
|
|
66
|
+
severity: diffSeverity(e.current, e.latest),
|
|
67
|
+
})),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if (rows.length === 0) {
|
|
71
|
+
console.log(pc.green('Everything up to date.'))
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const packageCount = new Set(rows.map((r) => r.dir)).size
|
|
76
|
+
const majorCount = rows.filter((r) => r.severity === 'major').length
|
|
77
|
+
const depWord = rows.length === 1 ? 'dependency' : 'dependencies'
|
|
78
|
+
const summary = `${rows.length} ${depWord} outdated across ${packageCount} package(s)`
|
|
79
|
+
console.log(pc.dim(summary) + (majorCount > 0 ? pc.red(`, ${majorCount} of them major version behind`) : ''))
|
|
80
|
+
|
|
81
|
+
printTable(
|
|
82
|
+
rows,
|
|
83
|
+
[
|
|
84
|
+
{ label: 'Package', value: (r) => r.dir },
|
|
85
|
+
{ label: 'Dependency', value: (r) => r.name },
|
|
86
|
+
{ label: 'Current', value: (r) => r.current, style: (r, t) => pc.dim(t) },
|
|
87
|
+
{ label: 'Wanted', value: (r) => r.wanted },
|
|
88
|
+
{ label: 'Latest', value: (r) => r.latest, style: (r, t) => (SEVERITY_COLOR[r.severity] ?? pc.dim)(t) },
|
|
89
|
+
],
|
|
90
|
+
{ groupBy: (r) => r.dir },
|
|
91
|
+
)
|
|
92
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import pc from 'picocolors'
|
|
2
|
+
import { discoverRepos } from '../repos.js'
|
|
3
|
+
import { loadConfig } from '../loadConfig.js'
|
|
4
|
+
import { ghAsync } from '../exec.js'
|
|
5
|
+
import { pMap } from '../pMap.js'
|
|
6
|
+
import { heading, printTable } from '../ui.js'
|
|
7
|
+
import { startSpinner } from '../spinner.js'
|
|
8
|
+
import { filterByNames } from '../filterByNames.js'
|
|
9
|
+
|
|
10
|
+
export async function prsCommand({ configPath, packages } = {}) {
|
|
11
|
+
const config = loadConfig({ configPath })
|
|
12
|
+
const repos = filterByNames(discoverRepos(config), packages)
|
|
13
|
+
if (repos.length === 0) {
|
|
14
|
+
console.log(pc.yellow('No repos found.'))
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
heading('Open pull requests')
|
|
19
|
+
|
|
20
|
+
const spinner = startSpinner(`Checking ${repos.length} package(s) for open pull requests...`)
|
|
21
|
+
const results = await pMap(repos, async (r) => {
|
|
22
|
+
const result = await ghAsync(r.path, [
|
|
23
|
+
'pr',
|
|
24
|
+
'list',
|
|
25
|
+
'--state',
|
|
26
|
+
'open',
|
|
27
|
+
'--json',
|
|
28
|
+
'number,title,headRefName,isDraft',
|
|
29
|
+
])
|
|
30
|
+
if (!result.ok || !result.stdout) return { repo: r, prs: [] }
|
|
31
|
+
try {
|
|
32
|
+
return { repo: r, prs: JSON.parse(result.stdout) }
|
|
33
|
+
} catch {
|
|
34
|
+
return { repo: r, prs: [] }
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
spinner.stop()
|
|
38
|
+
|
|
39
|
+
const rows = results.flatMap(({ repo, prs }) =>
|
|
40
|
+
prs.map((pr) => ({
|
|
41
|
+
dir: repo.dir,
|
|
42
|
+
number: `#${pr.number}`,
|
|
43
|
+
title: pr.title.length > 50 ? `${pr.title.slice(0, 47)}...` : pr.title,
|
|
44
|
+
branch: pr.headRefName,
|
|
45
|
+
draft: pr.isDraft,
|
|
46
|
+
})),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if (rows.length === 0) {
|
|
50
|
+
console.log(pc.green('No open pull requests.'))
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
printTable(
|
|
55
|
+
rows,
|
|
56
|
+
[
|
|
57
|
+
{ label: 'Package', value: (r) => r.dir },
|
|
58
|
+
{ label: 'PR', value: (r) => r.number, style: (r, t) => pc.dim(t) },
|
|
59
|
+
{ label: 'Title', value: (r) => r.title },
|
|
60
|
+
{ label: 'Branch', value: (r) => r.branch, style: (r, t) => pc.dim(t) },
|
|
61
|
+
{ label: 'Status', value: (r) => (r.draft ? 'draft' : ''), style: (r, t) => pc.yellow(t) },
|
|
62
|
+
],
|
|
63
|
+
{ groupBy: (r) => r.dir },
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts'
|
|
2
|
+
import pc from 'picocolors'
|
|
3
|
+
import { discoverRepos, inspectRepos } from '../repos.js'
|
|
4
|
+
import { loadConfig } from '../loadConfig.js'
|
|
5
|
+
import { npm } from '../exec.js'
|
|
6
|
+
import { fetchPublishedVersionAsync } from '../registry.js'
|
|
7
|
+
import { selectPackages } from '../selectPackages.js'
|
|
8
|
+
import { pMap } from '../pMap.js'
|
|
9
|
+
import { heading, stepHeading, ok, fail, columnWidths, formatRow } from '../ui.js'
|
|
10
|
+
|
|
11
|
+
export async function publishCommand({ configPath, packages, yes = false, dryRun = false } = {}) {
|
|
12
|
+
const config = loadConfig({ configPath })
|
|
13
|
+
const repos = (await inspectRepos(discoverRepos(config))).filter((r) => r.version)
|
|
14
|
+
if (repos.length === 0) {
|
|
15
|
+
console.log(pc.yellow('No repos found.'))
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
heading('Publish to npm')
|
|
20
|
+
|
|
21
|
+
console.log(pc.dim(`Checking ${repos.length} package(s) against the registry...`))
|
|
22
|
+
// Each repo's registry lookup is a real network round trip — running them
|
|
23
|
+
// all at once instead of one at a time is where this actually pays off.
|
|
24
|
+
// Printed as each one resolves, so the order below reflects how fast the
|
|
25
|
+
// registry answered, not the package list's order.
|
|
26
|
+
const withRegistry = await pMap(repos, async (r) => {
|
|
27
|
+
const published = await fetchPublishedVersionAsync(r)
|
|
28
|
+
const needsPublish = published !== r.version
|
|
29
|
+
console.log(
|
|
30
|
+
pc.dim(` ${r.dir}: registry ${published ?? '(not published)'} ${needsPublish ? '≠' : '='} local ${r.version}`),
|
|
31
|
+
)
|
|
32
|
+
return { ...r, published, needsPublish }
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const selected = await selectPackages({
|
|
36
|
+
items: withRegistry,
|
|
37
|
+
packages,
|
|
38
|
+
message: 'Pick packages to publish:',
|
|
39
|
+
buildChoice: (all) => {
|
|
40
|
+
const columns = [
|
|
41
|
+
{ value: (r) => r.dir },
|
|
42
|
+
{ value: (r) => r.published ?? '(none)', style: (r, t) => pc.dim(t) },
|
|
43
|
+
{ value: () => '→', style: (r, t) => pc.dim(t) },
|
|
44
|
+
{ value: (r) => r.version, style: (r, t) => (r.needsPublish ? pc.green(t) : pc.dim(t)) },
|
|
45
|
+
]
|
|
46
|
+
const widths = columnWidths(all, columns)
|
|
47
|
+
return (r) => ({
|
|
48
|
+
name: formatRow(r, columns, widths) + (r.needsPublish ? '' : pc.dim(' (already up to date)')),
|
|
49
|
+
value: r,
|
|
50
|
+
checked: r.needsPublish,
|
|
51
|
+
})
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
if (selected.length === 0) {
|
|
56
|
+
console.log(pc.dim('Nothing selected.'))
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!yes) {
|
|
61
|
+
const proceed = await confirm({
|
|
62
|
+
message: `Publish ${selected.length} package(s) to npm?${dryRun ? ' (dry run — nothing will actually be published)' : ''}`,
|
|
63
|
+
default: true,
|
|
64
|
+
})
|
|
65
|
+
if (!proceed) {
|
|
66
|
+
console.log(pc.dim('Cancelled.'))
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let index = 0
|
|
72
|
+
for (const repo of selected) {
|
|
73
|
+
index += 1
|
|
74
|
+
stepHeading(index, selected.length, `${repo.name}@${repo.version}`)
|
|
75
|
+
// Real terminal, not captured — npm publish can stop for a 2FA/OTP
|
|
76
|
+
// prompt, and --dry-run (npm's own flag) does a full dry run including
|
|
77
|
+
// packing, so this exercises the same path as a real publish.
|
|
78
|
+
const result = npm(repo.path, ['publish', ...(dryRun ? ['--dry-run'] : [])], { interactive: true })
|
|
79
|
+
if (!result.ok) fail(`npm publish failed (exit ${result.status}).`)
|
|
80
|
+
else ok(`Published ${repo.name}@${repo.version}${dryRun ? ' (dry run).' : '.'}`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts'
|
|
2
|
+
import pc from 'picocolors'
|
|
3
|
+
import { discoverRepos, inspectRepos } from '../repos.js'
|
|
4
|
+
import { loadConfig } from '../loadConfig.js'
|
|
5
|
+
import { tagName, tagExists } from '../tags.js'
|
|
6
|
+
import { releaseExistsAsync, createRelease } from '../release.js'
|
|
7
|
+
import { extractChangelogSection } from '../changelog.js'
|
|
8
|
+
import { selectPackages } from '../selectPackages.js'
|
|
9
|
+
import { pMap } from '../pMap.js'
|
|
10
|
+
import { heading, stepHeading, ok, fail, columnWidths, formatRow } from '../ui.js'
|
|
11
|
+
|
|
12
|
+
export async function releaseCommand({ configPath, packages, yes = false, dryRun = false } = {}) {
|
|
13
|
+
const config = loadConfig({ configPath })
|
|
14
|
+
const repos = (await inspectRepos(discoverRepos(config))).filter((r) => r.version)
|
|
15
|
+
if (repos.length === 0) {
|
|
16
|
+
console.log(pc.yellow('No repos found.'))
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
heading('GitHub releases')
|
|
21
|
+
|
|
22
|
+
console.log(pc.dim(`Checking ${repos.length} package(s) for a tag and an existing release...`))
|
|
23
|
+
// A release always targets `v<local version>` — the tag `polyrepo bump`
|
|
24
|
+
// creates. No tag for the current version means there's nothing to
|
|
25
|
+
// release yet; a tag with no release is exactly what this command is for.
|
|
26
|
+
const withStatus = await pMap(repos, async (r) => {
|
|
27
|
+
const tag = tagName(r.version)
|
|
28
|
+
const tagged = tagExists(r, tag)
|
|
29
|
+
const released = tagged ? await releaseExistsAsync(r, tag) : false
|
|
30
|
+
const status = !tagged ? 'no-tag' : released ? 'released' : 'ready'
|
|
31
|
+
const statusText = status === 'no-tag' ? 'not tagged yet' : status === 'released' ? 'already released' : 'ready'
|
|
32
|
+
console.log(pc.dim(` ${r.dir}: ${tag} — ${statusText}`))
|
|
33
|
+
return { ...r, tag, status }
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Every choice would be disabled ("no tag yet") — @inquirer/checkbox
|
|
37
|
+
// throws rather than just showing an empty list in that case, so this
|
|
38
|
+
// needs to be caught before ever calling selectPackages when the
|
|
39
|
+
// checkbox is actually going to render (a --packages run doesn't hit
|
|
40
|
+
// this, since it skips the checkbox and the per-repo loop below already
|
|
41
|
+
// reports "no tag" per package on its own).
|
|
42
|
+
if (!packages && withStatus.every((r) => r.status === 'no-tag')) {
|
|
43
|
+
console.log(
|
|
44
|
+
pc.yellow('No packages are tagged yet — run `polyrepo bump` (new version) or `polyrepo tag` (current version) first.'),
|
|
45
|
+
)
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const selected = await selectPackages({
|
|
50
|
+
items: withStatus,
|
|
51
|
+
packages,
|
|
52
|
+
message: 'Pick packages to create a GitHub Release for:',
|
|
53
|
+
buildChoice: (all) => {
|
|
54
|
+
const columns = [
|
|
55
|
+
{ value: (r) => r.dir },
|
|
56
|
+
{ value: (r) => r.tag, style: (r, t) => pc.dim(t) },
|
|
57
|
+
]
|
|
58
|
+
const widths = columnWidths(all, columns)
|
|
59
|
+
return (r) => ({
|
|
60
|
+
name: formatRow(r, columns, widths) + statusSuffix(r),
|
|
61
|
+
value: r,
|
|
62
|
+
checked: r.status === 'ready',
|
|
63
|
+
disabled: r.status === 'no-tag' ? '(no tag yet — run `polyrepo bump` or `polyrepo tag` first)' : false,
|
|
64
|
+
})
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
if (selected.length === 0) {
|
|
69
|
+
console.log(pc.dim('Nothing selected.'))
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!yes) {
|
|
74
|
+
const proceed = await confirm({
|
|
75
|
+
message: `Create ${selected.length} GitHub Release(s)?${dryRun ? ' (dry run — nothing will actually be created)' : ''}`,
|
|
76
|
+
default: true,
|
|
77
|
+
})
|
|
78
|
+
if (!proceed) {
|
|
79
|
+
console.log(pc.dim('Cancelled.'))
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let index = 0
|
|
85
|
+
for (const repo of selected) {
|
|
86
|
+
index += 1
|
|
87
|
+
stepHeading(index, selected.length, `${repo.dir} ${repo.tag}`)
|
|
88
|
+
|
|
89
|
+
// The checkbox disables "no-tag" choices so they can't be picked
|
|
90
|
+
// interactively, but --packages bypasses the checkbox entirely — this
|
|
91
|
+
// is the guard for that path (and cheap insurance against the status
|
|
92
|
+
// having gone stale between the check above and here).
|
|
93
|
+
if (repo.status === 'no-tag') {
|
|
94
|
+
fail(`No tag ${repo.tag} on origin — run \`polyrepo bump\` or \`polyrepo tag\` first.`)
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
releaseOne(repo, repo.tag, { dryRun })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Also used by `polyrepo tag` to offer "release what I just tagged" right after
|
|
103
|
+
// tagging, without making that command build its own checkbox/confirm and
|
|
104
|
+
// re-discover which packages are release-ready — it already knows exactly.
|
|
105
|
+
export function releaseOne(repo, tag, { dryRun } = {}) {
|
|
106
|
+
const notes = extractChangelogSection(repo, repo.version)
|
|
107
|
+
const title = `${repo.name}@${repo.version}`
|
|
108
|
+
ok(notes ? 'Using the matching CHANGELOG.md section as release notes.' : 'No changelog entry — using --generate-notes.')
|
|
109
|
+
|
|
110
|
+
const result = createRelease(repo, { tag, title, notes, dryRun })
|
|
111
|
+
if (!result.ok) fail(`gh release create failed (exit ${result.status}).`)
|
|
112
|
+
else ok(`Created release ${title}.`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// @inquirer/checkbox appends the `disabled` reason after the name itself
|
|
116
|
+
// for disabled choices, so "no-tag" needs nothing here — only "released"
|
|
117
|
+
// needs an explicit suffix, since it stays selectable (re-releasing is a
|
|
118
|
+
// legitimate thing to want) but shouldn't look identical to a fresh one.
|
|
119
|
+
function statusSuffix(r) {
|
|
120
|
+
return r.status === 'released' ? pc.dim(' (already released)') : ''
|
|
121
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import { select, input, confirm, Separator } from '@inquirer/prompts'
|
|
3
|
+
import pc from 'picocolors'
|
|
4
|
+
import { resolveConfigFilePath, readConfigFile, writeConfigFile } from '../configFile.js'
|
|
5
|
+
import { isRepo } from '../repos.js'
|
|
6
|
+
import { heading, ok, warn, promptTheme } from '../ui.js'
|
|
7
|
+
|
|
8
|
+
export async function setupCommand({ configPath } = {}) {
|
|
9
|
+
const filePath = resolveConfigFilePath(configPath)
|
|
10
|
+
const data = readConfigFile(filePath)
|
|
11
|
+
let dirty = false
|
|
12
|
+
|
|
13
|
+
heading('Configure package sources')
|
|
14
|
+
console.log(pc.dim(`Editing: ${filePath}`))
|
|
15
|
+
|
|
16
|
+
while (true) {
|
|
17
|
+
console.log('')
|
|
18
|
+
printEntries('roots (subfolders are scanned as packages)', data.roots, describeRoot)
|
|
19
|
+
console.log('')
|
|
20
|
+
printEntries('packages (the folder itself is the package)', data.packages, describePackage)
|
|
21
|
+
|
|
22
|
+
const choices = [
|
|
23
|
+
{ name: 'Add a root directory', value: 'add-root' },
|
|
24
|
+
{ name: 'Add a single package directory', value: 'add-package' },
|
|
25
|
+
]
|
|
26
|
+
if (data.roots.length + data.packages.length > 0) {
|
|
27
|
+
choices.push(
|
|
28
|
+
{ name: 'Edit an entry', value: 'edit' },
|
|
29
|
+
{ name: 'Remove an entry', value: 'remove' },
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
choices.push(new Separator())
|
|
33
|
+
choices.push({ name: dirty ? 'Save and exit' : 'Exit', value: 'exit' })
|
|
34
|
+
if (dirty) choices.push({ name: 'Discard changes and exit', value: 'discard' })
|
|
35
|
+
|
|
36
|
+
console.log('')
|
|
37
|
+
const action = await select({ message: 'What do you want to do?', choices, theme: promptTheme })
|
|
38
|
+
|
|
39
|
+
if (action === 'add-root') {
|
|
40
|
+
const value = await promptPath('Root directory (its direct subfolders are scanned as packages):')
|
|
41
|
+
if (value) {
|
|
42
|
+
data.roots.push(value)
|
|
43
|
+
dirty = true
|
|
44
|
+
}
|
|
45
|
+
} else if (action === 'add-package') {
|
|
46
|
+
const value = await promptPath('Package directory (this folder itself is the package):')
|
|
47
|
+
if (value) {
|
|
48
|
+
data.packages.push(value)
|
|
49
|
+
dirty = true
|
|
50
|
+
}
|
|
51
|
+
} else if (action === 'edit') {
|
|
52
|
+
const picked = await pickEntry(data)
|
|
53
|
+
if (picked) {
|
|
54
|
+
const current = data[picked.kind][picked.index]
|
|
55
|
+
const label = picked.kind === 'roots' ? 'root' : 'package directory'
|
|
56
|
+
const value = await promptPath(`New value for this ${label}:`, current)
|
|
57
|
+
if (value) {
|
|
58
|
+
data[picked.kind][picked.index] = value
|
|
59
|
+
dirty = true
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} else if (action === 'remove') {
|
|
63
|
+
const picked = await pickEntry(data)
|
|
64
|
+
if (picked) {
|
|
65
|
+
const current = data[picked.kind][picked.index]
|
|
66
|
+
const reallyRemove = await confirm({ message: `Remove "${current}"?`, default: false })
|
|
67
|
+
if (reallyRemove) {
|
|
68
|
+
data[picked.kind].splice(picked.index, 1)
|
|
69
|
+
dirty = true
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} else if (action === 'exit') {
|
|
73
|
+
if (dirty) {
|
|
74
|
+
writeConfigFile(filePath, data)
|
|
75
|
+
ok(`Saved ${filePath}`)
|
|
76
|
+
}
|
|
77
|
+
return
|
|
78
|
+
} else if (action === 'discard') {
|
|
79
|
+
console.log(pc.dim('Discarded changes.'))
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function printEntries(label, list, describe) {
|
|
86
|
+
console.log(pc.bold(label))
|
|
87
|
+
if (list.length === 0) {
|
|
88
|
+
console.log(pc.dim(' (none)'))
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
list.forEach((p, i) => console.log(` ${i + 1}. ${describe(p)}`))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function describeRoot(p) {
|
|
95
|
+
return `${p} ${fs.existsSync(p) ? pc.green('✓ exists') : pc.red('✗ not found')}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function describePackage(p) {
|
|
99
|
+
if (!fs.existsSync(p)) return `${p} ${pc.red('✗ not found')}`
|
|
100
|
+
if (!isRepo(p)) return `${p} ${pc.yellow('! no package.json/.git here')}`
|
|
101
|
+
return `${p} ${pc.green('✓ ok')}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function promptPath(message, defaultValue) {
|
|
105
|
+
const value = await input({ message, default: defaultValue })
|
|
106
|
+
const trimmed = value.trim()
|
|
107
|
+
if (!trimmed) return null
|
|
108
|
+
if (!fs.existsSync(trimmed)) {
|
|
109
|
+
warn(`Path does not exist yet — added anyway: ${trimmed}`)
|
|
110
|
+
}
|
|
111
|
+
return trimmed
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function pickEntry(data) {
|
|
115
|
+
const choices = [
|
|
116
|
+
...data.roots.map((p, i) => ({ name: `[root] ${p}`, value: { kind: 'roots', index: i } })),
|
|
117
|
+
...data.packages.map((p, i) => ({ name: `[package] ${p}`, value: { kind: 'packages', index: i } })),
|
|
118
|
+
{ name: 'Cancel', value: null },
|
|
119
|
+
]
|
|
120
|
+
return select({ message: 'Which entry?', choices, theme: promptTheme })
|
|
121
|
+
}
|