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/src/index.js
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander'
|
|
3
|
+
import pc from 'picocolors'
|
|
4
|
+
import { listCommand } from './commands/list.js'
|
|
5
|
+
import { doctorCommand } from './commands/doctor.js'
|
|
6
|
+
import { switchMasterCommand } from './commands/switchMaster.js'
|
|
7
|
+
import { bumpCommand } from './commands/bump.js'
|
|
8
|
+
import { publishCommand } from './commands/publish.js'
|
|
9
|
+
import { tagCommand } from './commands/tag.js'
|
|
10
|
+
import { releaseCommand } from './commands/release.js'
|
|
11
|
+
import { setupCommand } from './commands/setup.js'
|
|
12
|
+
import { execCommand } from './commands/exec.js'
|
|
13
|
+
import { outdatedCommand } from './commands/outdated.js'
|
|
14
|
+
import { prsCommand } from './commands/prs.js'
|
|
15
|
+
import { cloneCommand } from './commands/clone.js'
|
|
16
|
+
|
|
17
|
+
const program = new Command()
|
|
18
|
+
|
|
19
|
+
const PACKAGES_OPTION = [
|
|
20
|
+
'--packages <names>',
|
|
21
|
+
'Comma-separated package dir names, skipping the interactive picker (e.g. --packages vue-toast-kit,os-detect).',
|
|
22
|
+
]
|
|
23
|
+
const YES_OPTION = ['--yes', 'Skip the "proceed?" confirmation.']
|
|
24
|
+
|
|
25
|
+
// commander's built-in "Commands:" list puts each command's flags inline
|
|
26
|
+
// after its name ("bump [--dry-run] [--packages <names>] [--yes]") — fine
|
|
27
|
+
// for one flag, cramped for three: the name column has to widen to fit the
|
|
28
|
+
// longest one, which pushes every description far to the right and leaves
|
|
29
|
+
// too little room left for them to wrap. This builds the same information
|
|
30
|
+
// as a two-level list instead — flags on their own indented line under
|
|
31
|
+
// their command — which is what stays readable once a command has more
|
|
32
|
+
// than one or two options.
|
|
33
|
+
function formatCommandsHelp(commands) {
|
|
34
|
+
// Grouped per command (not one flat row list) so a blank line can go
|
|
35
|
+
// between groups — without the grouping, nothing marks where one
|
|
36
|
+
// command's options end and the next command's name begins.
|
|
37
|
+
const groups = commands
|
|
38
|
+
.filter((cmd) => cmd.name() !== 'help')
|
|
39
|
+
.map((cmd) => {
|
|
40
|
+
const aliases = cmd.aliases()
|
|
41
|
+
const label = aliases.length ? `${cmd.name()}, ${aliases.join(', ')}` : cmd.name()
|
|
42
|
+
return [
|
|
43
|
+
{ indent: 0, label, desc: cmd.description() },
|
|
44
|
+
...cmd.options.map((opt) => ({ indent: 1, label: opt.flags, desc: opt.description })),
|
|
45
|
+
]
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const allRows = groups.flat()
|
|
49
|
+
const labelWidth = Math.max(...allRows.map((r) => r.indent * 4 + r.label.length))
|
|
50
|
+
const totalWidth = (process.stdout.isTTY && process.stdout.columns) || 96
|
|
51
|
+
const descWidth = Math.max(totalWidth - (2 + labelWidth + 2), 30)
|
|
52
|
+
|
|
53
|
+
const lines = ['Commands:']
|
|
54
|
+
groups.forEach((group, i) => {
|
|
55
|
+
if (i > 0) lines.push('')
|
|
56
|
+
for (const r of group) {
|
|
57
|
+
const fullLabel = ' '.repeat(r.indent * 4) + r.label
|
|
58
|
+
const [firstLine, ...restLines] = wrapText(r.desc, descWidth)
|
|
59
|
+
lines.push(` ${fullLabel.padEnd(labelWidth)} ${firstLine}`)
|
|
60
|
+
for (const cont of restLines) {
|
|
61
|
+
lines.push(` ${' '.repeat(labelWidth)} ${cont}`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
return lines.join('\n')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function wrapText(text, width) {
|
|
69
|
+
const words = text.split(' ')
|
|
70
|
+
const lines = []
|
|
71
|
+
let current = ''
|
|
72
|
+
for (const word of words) {
|
|
73
|
+
const candidate = current ? `${current} ${word}` : word
|
|
74
|
+
if (current && candidate.length > width) {
|
|
75
|
+
lines.push(current)
|
|
76
|
+
current = word
|
|
77
|
+
} else {
|
|
78
|
+
current = candidate
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (current) lines.push(current)
|
|
82
|
+
return lines
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
program
|
|
86
|
+
.name('polyrepo')
|
|
87
|
+
.description(
|
|
88
|
+
'Manage local npm package repos: pick which directories to scan (setup), clone missing ones from GitHub (clone), see their state (list), outdated dependencies (outdated), or open PRs (prs), run a health check (doctor), keep them on an up-to-date master (switch-master), release a version through a PR (bump), publish to npm (publish), tag an already-current version (tag), create GitHub Releases (release), or run any command across every repo (exec).',
|
|
89
|
+
)
|
|
90
|
+
.version('1.0.0')
|
|
91
|
+
.option(
|
|
92
|
+
'--config <path>',
|
|
93
|
+
'Path to a polyrepo.config.json listing roots/packages to scan (default: polyrepo.config.json next to this CLI).',
|
|
94
|
+
)
|
|
95
|
+
// Hide commander's own one-line-per-command list (see formatCommandsHelp
|
|
96
|
+
// above for why) — the "after" text below replaces it with the two-level
|
|
97
|
+
// version instead of showing both.
|
|
98
|
+
.configureHelp({ visibleCommands: () => [] })
|
|
99
|
+
.addHelpText(
|
|
100
|
+
'after',
|
|
101
|
+
() => `
|
|
102
|
+
${formatCommandsHelp(program.commands)}
|
|
103
|
+
|
|
104
|
+
Getting started:
|
|
105
|
+
First run \`polyrepo setup\` to tell it where your package repos live — a folder
|
|
106
|
+
of repos (a "root", subfolders are scanned) and/or individual repo folders
|
|
107
|
+
("packages"). \`polyrepo doctor\` checks the rest of your setup (git/gh/npm,
|
|
108
|
+
auth, cross-package dependency drift). Everything else reads the same
|
|
109
|
+
package list.
|
|
110
|
+
|
|
111
|
+
Examples:
|
|
112
|
+
$ polyrepo setup Add/edit/remove package source directories
|
|
113
|
+
$ polyrepo clone --org my-org Clone repos from GitHub that aren't local yet
|
|
114
|
+
$ polyrepo doctor Check environment, auth, and dependency drift
|
|
115
|
+
$ polyrepo list Show version + branch for every package
|
|
116
|
+
$ polyrepo outdated Show outdated dependencies across every package
|
|
117
|
+
$ polyrepo prs List open pull requests across every package
|
|
118
|
+
$ polyrepo switch-master Update selected repos to the latest master
|
|
119
|
+
$ polyrepo bump --dry-run Preview a version bump, nothing is pushed
|
|
120
|
+
$ polyrepo bump --minor --packages a,b --yes Bump specific packages' minor version, non-interactively
|
|
121
|
+
$ polyrepo publish Publish packages that are ahead of the registry
|
|
122
|
+
$ polyrepo tag Tag an already-current version (no bump needed)
|
|
123
|
+
$ polyrepo release Create GitHub Releases for tagged packages
|
|
124
|
+
$ polyrepo exec -- npm test Run any command across every (or selected) package
|
|
125
|
+
|
|
126
|
+
Run \`polyrepo <command> --help\` for that command's own options and examples.
|
|
127
|
+
`,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
program
|
|
131
|
+
.command('setup')
|
|
132
|
+
.description('View, add, edit, or remove the roots/packages entries in polyrepo.config.json.')
|
|
133
|
+
.addHelpText(
|
|
134
|
+
'after',
|
|
135
|
+
`
|
|
136
|
+
A "root" is a folder whose direct subfolders are packages (e.g. C:\\work\\NPM).
|
|
137
|
+
A "package" is a single repo folder given directly, for one that doesn't live
|
|
138
|
+
under any root. Both are edited through the same menu; nothing is written to
|
|
139
|
+
disk until you choose "Save and exit".
|
|
140
|
+
|
|
141
|
+
Examples:
|
|
142
|
+
$ polyrepo setup
|
|
143
|
+
$ polyrepo setup --config "D:\\other\\polyrepo.config.json" Edit a different config file
|
|
144
|
+
`,
|
|
145
|
+
)
|
|
146
|
+
.action(() => setupCommand({ configPath: program.opts().config }))
|
|
147
|
+
|
|
148
|
+
program
|
|
149
|
+
.command('clone')
|
|
150
|
+
.description("Clone repos from a GitHub org/user that aren't already present under a root.")
|
|
151
|
+
.requiredOption('--org <name>', 'GitHub org or user to list repos from.')
|
|
152
|
+
.option('--root <path>', "Root directory to clone into (default: the config's first root).")
|
|
153
|
+
.option('--include-archived', 'Also offer archived repos (skipped by default).')
|
|
154
|
+
.option(...PACKAGES_OPTION)
|
|
155
|
+
.option(...YES_OPTION)
|
|
156
|
+
.option('--dry-run', 'Print what would be cloned, without actually cloning anything.')
|
|
157
|
+
.addHelpText(
|
|
158
|
+
'after',
|
|
159
|
+
`
|
|
160
|
+
Lists every repo under --org (via \`gh repo list\`), compares it against the
|
|
161
|
+
directory names already found under the target root, and offers to clone
|
|
162
|
+
whatever's missing. Archived repos are skipped by default (--include-archived
|
|
163
|
+
to include them). \`--packages\` here means "only offer these repo names",
|
|
164
|
+
same as everywhere else. Cloning into a root that isn't in your config yet
|
|
165
|
+
still works — you're just reminded to run \`polyrepo setup\` afterward so
|
|
166
|
+
\`list\`/\`doctor\`/etc. pick the new repos up too.
|
|
167
|
+
|
|
168
|
+
Examples:
|
|
169
|
+
$ polyrepo clone --org my-github-org
|
|
170
|
+
$ polyrepo clone --org my-github-org --root "C:\\work\\NPM" --yes
|
|
171
|
+
$ polyrepo clone --org my-github-org --dry-run
|
|
172
|
+
`,
|
|
173
|
+
)
|
|
174
|
+
.action((opts) =>
|
|
175
|
+
cloneCommand({
|
|
176
|
+
configPath: program.opts().config,
|
|
177
|
+
org: opts.org,
|
|
178
|
+
root: opts.root,
|
|
179
|
+
includeArchived: Boolean(opts.includeArchived),
|
|
180
|
+
packages: opts.packages ? opts.packages.split(',') : undefined,
|
|
181
|
+
yes: Boolean(opts.yes),
|
|
182
|
+
dryRun: Boolean(opts.dryRun),
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
program
|
|
187
|
+
.command('list')
|
|
188
|
+
.alias('ls')
|
|
189
|
+
.description('Show version, branch, tag, release, npm, and dependency status for every package.')
|
|
190
|
+
.option('--quick', 'Skip the tag/release/npm/dependency checks — just version, branch, and git status.')
|
|
191
|
+
.option('--path', "Add a Path column showing each package's location on disk.")
|
|
192
|
+
.option('--output <path>', 'Also save the table to this file, for sending the data somewhere.')
|
|
193
|
+
.option(
|
|
194
|
+
'--format <type>',
|
|
195
|
+
'File format for --output: md, json, csv, html, or txt. Guessed from the file extension if omitted.',
|
|
196
|
+
)
|
|
197
|
+
.addHelpText(
|
|
198
|
+
'after',
|
|
199
|
+
`
|
|
200
|
+
Read-only — safe to run any time. By default, for every package: local
|
|
201
|
+
version, branch, git status (clean/dirty), whether the current version is
|
|
202
|
+
tagged, whether that tag has a GitHub Release, whether the npm registry
|
|
203
|
+
matches (or the registry version if it doesn't, or "unpublished"), and how
|
|
204
|
+
many other local packages reference it with a now-stale dependency range
|
|
205
|
+
(see \`polyrepo doctor\`). Everything (git, tag, release, npm) runs in parallel
|
|
206
|
+
across packages, not one at a time — still not instant with the extra
|
|
207
|
+
network checks, so use --quick for just version/branch/git when that's
|
|
208
|
+
all you need. --path adds a Path column right after Package, for when a
|
|
209
|
+
package's directory name alone doesn't tell you where it actually lives
|
|
210
|
+
(e.g. it was found via a --packages entry, not a root).
|
|
211
|
+
|
|
212
|
+
--output saves the exact same table (same columns as printed — respects
|
|
213
|
+
--quick/--path) to a file, in addition to printing it. --format picks the
|
|
214
|
+
file format (md/json/csv/html/txt); without it, the format is guessed from
|
|
215
|
+
--output's extension (.json → json, .md/.markdown → markdown, .csv → csv,
|
|
216
|
+
.html/.htm → html, anything else → plain text).
|
|
217
|
+
|
|
218
|
+
Examples:
|
|
219
|
+
$ polyrepo list
|
|
220
|
+
$ polyrepo ls
|
|
221
|
+
$ polyrepo list --quick Just version/branch/git, no network calls
|
|
222
|
+
$ polyrepo list --path Also show each package's directory
|
|
223
|
+
$ polyrepo list --output packages.json Also save as JSON (format guessed from extension)
|
|
224
|
+
$ polyrepo list --output report.txt --format md Save as Markdown despite the .txt name
|
|
225
|
+
`,
|
|
226
|
+
)
|
|
227
|
+
.action((opts) =>
|
|
228
|
+
listCommand({
|
|
229
|
+
configPath: program.opts().config,
|
|
230
|
+
quick: Boolean(opts.quick),
|
|
231
|
+
showPath: Boolean(opts.path),
|
|
232
|
+
format: opts.format,
|
|
233
|
+
output: opts.output,
|
|
234
|
+
}),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
program
|
|
238
|
+
.command('outdated')
|
|
239
|
+
.description('Show outdated dependencies across every package (npm outdated).')
|
|
240
|
+
.option(...PACKAGES_OPTION)
|
|
241
|
+
.addHelpText(
|
|
242
|
+
'after',
|
|
243
|
+
`
|
|
244
|
+
Read-only. Runs \`npm outdated --json\` for every package in parallel and
|
|
245
|
+
prints one flat table: package, dependency, current/wanted/latest version.
|
|
246
|
+
Packages with nothing outdated don't add any rows. \`--packages\` here just
|
|
247
|
+
narrows which packages are checked — there's no checkbox, nothing to
|
|
248
|
+
confirm.
|
|
249
|
+
|
|
250
|
+
Examples:
|
|
251
|
+
$ polyrepo outdated
|
|
252
|
+
$ polyrepo outdated --packages vue-toast-kit,os-detect
|
|
253
|
+
`,
|
|
254
|
+
)
|
|
255
|
+
.action((opts) =>
|
|
256
|
+
outdatedCommand({
|
|
257
|
+
configPath: program.opts().config,
|
|
258
|
+
packages: opts.packages ? opts.packages.split(',') : undefined,
|
|
259
|
+
}),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
program
|
|
263
|
+
.command('prs')
|
|
264
|
+
.description('List open pull requests across every package.')
|
|
265
|
+
.option(...PACKAGES_OPTION)
|
|
266
|
+
.addHelpText(
|
|
267
|
+
'after',
|
|
268
|
+
`
|
|
269
|
+
Read-only. Runs \`gh pr list\` for every package in parallel and prints one
|
|
270
|
+
flat table: package, PR number, title, branch, draft status. Packages with
|
|
271
|
+
no open PRs don't add any rows — useful after an interrupted \`polyrepo bump\`
|
|
272
|
+
run to see which packages still have a PR waiting to be merged by hand.
|
|
273
|
+
|
|
274
|
+
Examples:
|
|
275
|
+
$ polyrepo prs
|
|
276
|
+
$ polyrepo prs --packages vue-toast-kit,os-detect
|
|
277
|
+
`,
|
|
278
|
+
)
|
|
279
|
+
.action((opts) =>
|
|
280
|
+
prsCommand({
|
|
281
|
+
configPath: program.opts().config,
|
|
282
|
+
packages: opts.packages ? opts.packages.split(',') : undefined,
|
|
283
|
+
}),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
program
|
|
287
|
+
.command('doctor')
|
|
288
|
+
.description('Check environment (node/git/gh/npm, auth), config, and cross-package dependency drift.')
|
|
289
|
+
.addHelpText(
|
|
290
|
+
'after',
|
|
291
|
+
`
|
|
292
|
+
Read-only. Three sections: Environment (is Node.js new enough, are git/gh/npm
|
|
293
|
+
on PATH and authenticated), Config (does polyrepo.config.json resolve to any
|
|
294
|
+
packages, which repos are dirty or off master), and Cross-package
|
|
295
|
+
dependencies (does any local package's dependencies/devDependencies/
|
|
296
|
+
peerDependencies range no longer match another local package's current
|
|
297
|
+
version — e.g. after a \`bump\` that package's own package.json wasn't
|
|
298
|
+
updated for). Run this first if any other command is behaving strangely.
|
|
299
|
+
|
|
300
|
+
Examples:
|
|
301
|
+
$ polyrepo doctor
|
|
302
|
+
`,
|
|
303
|
+
)
|
|
304
|
+
.action(() => doctorCommand({ configPath: program.opts().config }))
|
|
305
|
+
|
|
306
|
+
program
|
|
307
|
+
.command('switch-master')
|
|
308
|
+
.alias('sm')
|
|
309
|
+
.description('Pick repos and switch each to an up-to-date master.')
|
|
310
|
+
.option(...PACKAGES_OPTION)
|
|
311
|
+
.option(...YES_OPTION)
|
|
312
|
+
.addHelpText(
|
|
313
|
+
'after',
|
|
314
|
+
`
|
|
315
|
+
For each selected repo: fetch, checkout master, fast-forward-only merge.
|
|
316
|
+
A repo with uncommitted changes is skipped with a warning, never touched.
|
|
317
|
+
If local master has diverged from origin (fast-forward impossible), that
|
|
318
|
+
repo is reported and left alone for you to resolve by hand.
|
|
319
|
+
|
|
320
|
+
Examples:
|
|
321
|
+
$ polyrepo switch-master
|
|
322
|
+
$ polyrepo sm --packages vue-toast-kit,os-detect --yes
|
|
323
|
+
`,
|
|
324
|
+
)
|
|
325
|
+
.action((opts) =>
|
|
326
|
+
switchMasterCommand({
|
|
327
|
+
configPath: program.opts().config,
|
|
328
|
+
packages: opts.packages ? opts.packages.split(',') : undefined,
|
|
329
|
+
yes: Boolean(opts.yes),
|
|
330
|
+
}),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
program
|
|
334
|
+
.command('bump')
|
|
335
|
+
.description('Pick packages, bump their version (patch by default), PR, merge to master, and tag.')
|
|
336
|
+
.option('--dry-run', 'Print every step without pushing, opening, merging, or tagging anything for real.')
|
|
337
|
+
.option('--minor', 'Bump the minor version instead of patch (e.g. 1.2.9 → 1.3.0).')
|
|
338
|
+
.option('--major', 'Bump the major version instead of patch (e.g. 1.2.9 → 2.0.0).')
|
|
339
|
+
.option(...PACKAGES_OPTION)
|
|
340
|
+
.option(...YES_OPTION)
|
|
341
|
+
.option('--wait-checks', 'Wait for CI checks on the PR (if any are configured) before merging; abort if they fail.')
|
|
342
|
+
.addHelpText(
|
|
343
|
+
'after',
|
|
344
|
+
`
|
|
345
|
+
Branch, PR, merge, and tag — no npm publish here, that's its own command
|
|
346
|
+
(see \`polyrepo publish\`; for a GitHub Release from the resulting tag, see
|
|
347
|
+
\`polyrepo release\`). Bumps the patch version by default; \`--minor\`/\`--major\`
|
|
348
|
+
bump that part instead (resetting the parts below it to 0, same as any
|
|
349
|
+
semver tool). Safe to re-run: if a previous attempt already pushed a
|
|
350
|
+
branch, opened a PR, or even merged it, this picks up from there instead of
|
|
351
|
+
failing or duplicating work. If the package has a CHANGELOG.md, a draft
|
|
352
|
+
entry (Keep a Changelog style, seeded from the commit log since the last
|
|
353
|
+
tag) is added to the same commit — review/edit it before merging if you
|
|
354
|
+
want it polished. After every package is processed, any other local
|
|
355
|
+
package whose dependencies/peerDependencies/devDependencies no longer
|
|
356
|
+
match a bumped package's new version is reported (nothing is changed
|
|
357
|
+
automatically).
|
|
358
|
+
|
|
359
|
+
Examples:
|
|
360
|
+
$ polyrepo bump --dry-run See the plan, nothing changes
|
|
361
|
+
$ polyrepo bump Interactive: checkbox + confirm, patch bump
|
|
362
|
+
$ polyrepo bump --minor Interactive minor bump
|
|
363
|
+
$ polyrepo bump --wait-checks Wait for CI to go green before merging
|
|
364
|
+
$ polyrepo bump --packages a,b --yes Non-interactive, for scripts/CI
|
|
365
|
+
`,
|
|
366
|
+
)
|
|
367
|
+
.action((opts) => {
|
|
368
|
+
if (opts.minor && opts.major) {
|
|
369
|
+
console.error('Cannot combine --minor and --major — pick one.')
|
|
370
|
+
process.exitCode = 1
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
return bumpCommand({
|
|
374
|
+
dryRun: Boolean(opts.dryRun),
|
|
375
|
+
configPath: program.opts().config,
|
|
376
|
+
packages: opts.packages ? opts.packages.split(',') : undefined,
|
|
377
|
+
yes: Boolean(opts.yes),
|
|
378
|
+
waitChecks: Boolean(opts.waitChecks),
|
|
379
|
+
bumpType: opts.major ? 'major' : opts.minor ? 'minor' : 'patch',
|
|
380
|
+
})
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
program
|
|
384
|
+
.command('publish')
|
|
385
|
+
.description('Pick packages and run "npm publish" — pre-selects ones ahead of the registry.')
|
|
386
|
+
.option('--dry-run', 'Run "npm publish --dry-run" instead of a real publish.')
|
|
387
|
+
.option(...PACKAGES_OPTION)
|
|
388
|
+
.option(...YES_OPTION)
|
|
389
|
+
.addHelpText(
|
|
390
|
+
'after',
|
|
391
|
+
`
|
|
392
|
+
Compares each package's local version against the npm registry first (in
|
|
393
|
+
parallel — one round trip per package, not one after another), so the
|
|
394
|
+
checkbox pre-selects only what's actually ahead. Runs with a real terminal
|
|
395
|
+
(not captured), so an npm 2FA/OTP prompt works normally.
|
|
396
|
+
|
|
397
|
+
Examples:
|
|
398
|
+
$ polyrepo publish See what needs publishing, then publish it
|
|
399
|
+
$ polyrepo publish --dry-run Full build + pack, nothing actually published
|
|
400
|
+
$ polyrepo publish --packages a,b --yes Non-interactive, for scripts/CI
|
|
401
|
+
`,
|
|
402
|
+
)
|
|
403
|
+
.action((opts) =>
|
|
404
|
+
publishCommand({
|
|
405
|
+
dryRun: Boolean(opts.dryRun),
|
|
406
|
+
configPath: program.opts().config,
|
|
407
|
+
packages: opts.packages ? opts.packages.split(',') : undefined,
|
|
408
|
+
yes: Boolean(opts.yes),
|
|
409
|
+
}),
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
program
|
|
413
|
+
.command('tag')
|
|
414
|
+
.description('Tag selected packages at their current version, without bumping — then optionally release.')
|
|
415
|
+
.option('--dry-run', 'Print every step without actually tagging, pushing, or releasing anything.')
|
|
416
|
+
.option(...PACKAGES_OPTION)
|
|
417
|
+
.option(...YES_OPTION)
|
|
418
|
+
.option('--release', 'After tagging, create a GitHub Release for each package just tagged, without asking.')
|
|
419
|
+
.addHelpText(
|
|
420
|
+
'after',
|
|
421
|
+
`
|
|
422
|
+
For a package whose version was bumped some other way (not through
|
|
423
|
+
\`polyrepo bump\`, or before it started tagging) — puts the \`v<version>\` tag on
|
|
424
|
+
master's current tip, no version change and no PR, so \`polyrepo release\` has
|
|
425
|
+
something to work from. Re-syncs master first for each package, same as
|
|
426
|
+
\`bump\` does. Already-tagged packages are shown but unchecked by default
|
|
427
|
+
(picking one anyway just confirms the tag is there, harmless). After
|
|
428
|
+
tagging, asks whether to create a GitHub Release right away for whatever
|
|
429
|
+
was just tagged (same as running \`polyrepo release\` for exactly those
|
|
430
|
+
packages) — \`--release\` answers that yes without asking, for scripts.
|
|
431
|
+
|
|
432
|
+
Examples:
|
|
433
|
+
$ polyrepo tag See what needs tagging, tag it, then offered to release
|
|
434
|
+
$ polyrepo tag --dry-run Print the plan, tag and release nothing
|
|
435
|
+
$ polyrepo tag --packages a,b --yes --release Non-interactive: tag and release, for scripts/CI
|
|
436
|
+
`,
|
|
437
|
+
)
|
|
438
|
+
.action((opts) =>
|
|
439
|
+
tagCommand({
|
|
440
|
+
dryRun: Boolean(opts.dryRun),
|
|
441
|
+
configPath: program.opts().config,
|
|
442
|
+
packages: opts.packages ? opts.packages.split(',') : undefined,
|
|
443
|
+
yes: Boolean(opts.yes),
|
|
444
|
+
release: Boolean(opts.release),
|
|
445
|
+
}),
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
program
|
|
449
|
+
.command('release')
|
|
450
|
+
.description('Pick packages and create a GitHub Release for their current version\'s tag.')
|
|
451
|
+
.option('--dry-run', 'Print what would be created, without actually creating any release.')
|
|
452
|
+
.option(...PACKAGES_OPTION)
|
|
453
|
+
.option(...YES_OPTION)
|
|
454
|
+
.addHelpText(
|
|
455
|
+
'after',
|
|
456
|
+
`
|
|
457
|
+
A release always targets the tag \`polyrepo bump\` (or \`polyrepo tag\`, for a version
|
|
458
|
+
that was already correct) already created for the package's current
|
|
459
|
+
version (\`v<version>\`, e.g. v1.2.10) — \`--verify-tag\` is passed to
|
|
460
|
+
\`gh release create\` so it fails loudly instead of inventing one. Packages
|
|
461
|
+
with no tag yet for their current version show up disabled in the
|
|
462
|
+
checkbox ("run \`polyrepo bump\` first"); ones already released are selectable
|
|
463
|
+
but unchecked, in case you want to re-run it. Release notes come from the
|
|
464
|
+
matching CHANGELOG.md section when there is one, otherwise from gh's own
|
|
465
|
+
--generate-notes (summarizing merged PRs/commits).
|
|
466
|
+
|
|
467
|
+
Examples:
|
|
468
|
+
$ polyrepo release See what's tagged but not released, then release it
|
|
469
|
+
$ polyrepo release --dry-run Print the plan, create nothing
|
|
470
|
+
$ polyrepo release --packages a,b --yes Non-interactive, for scripts/CI
|
|
471
|
+
`,
|
|
472
|
+
)
|
|
473
|
+
.action((opts) =>
|
|
474
|
+
releaseCommand({
|
|
475
|
+
dryRun: Boolean(opts.dryRun),
|
|
476
|
+
configPath: program.opts().config,
|
|
477
|
+
packages: opts.packages ? opts.packages.split(',') : undefined,
|
|
478
|
+
yes: Boolean(opts.yes),
|
|
479
|
+
}),
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
program
|
|
483
|
+
.command('exec')
|
|
484
|
+
.description('Run an arbitrary command in each selected package.')
|
|
485
|
+
.argument('<cmd...>', 'Command to run, after a literal -- (e.g. `polyrepo exec -- npm test`).')
|
|
486
|
+
.option(...PACKAGES_OPTION)
|
|
487
|
+
.option(...YES_OPTION)
|
|
488
|
+
.option('--bail', 'Stop at the first package that exits non-zero, instead of continuing through the rest.')
|
|
489
|
+
.addHelpText(
|
|
490
|
+
'after',
|
|
491
|
+
`
|
|
492
|
+
Shows a checkbox of every discovered package (all checked by default —
|
|
493
|
+
"run everywhere" is the common case), then runs the given command in each
|
|
494
|
+
selected one, one at a time, with a real terminal (its output, colors, and
|
|
495
|
+
any prompts show up normally). A package that exits non-zero is reported
|
|
496
|
+
and, by default, the run continues with the rest — pass --bail to stop
|
|
497
|
+
immediately instead. A summary of any failed packages is printed at the
|
|
498
|
+
end, and the process exits non-zero if any package failed.
|
|
499
|
+
|
|
500
|
+
The command itself must come after a literal --, same as \`npm run <script> --\`
|
|
501
|
+
— anything before it is parsed as polyrepo's own options.
|
|
502
|
+
|
|
503
|
+
Examples:
|
|
504
|
+
$ polyrepo exec -- npm test Run tests everywhere
|
|
505
|
+
$ polyrepo exec --packages a,b --yes -- npm outdated Non-interactive, specific packages
|
|
506
|
+
$ polyrepo exec --bail -- npm run lint Stop at the first package that fails lint
|
|
507
|
+
`,
|
|
508
|
+
)
|
|
509
|
+
.action((cmd, opts) =>
|
|
510
|
+
execCommand({
|
|
511
|
+
configPath: program.opts().config,
|
|
512
|
+
packages: opts.packages ? opts.packages.split(',') : undefined,
|
|
513
|
+
yes: Boolean(opts.yes),
|
|
514
|
+
bail: Boolean(opts.bail),
|
|
515
|
+
cmd,
|
|
516
|
+
}),
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
520
|
+
// Ctrl+C during any @inquirer prompt (checkbox/select/confirm/input)
|
|
521
|
+
// rejects with this instead of just resolving to nothing — left
|
|
522
|
+
// uncaught, Node prints the full internal readline/keypress stack
|
|
523
|
+
// trace, which reads like a real crash even though the user just
|
|
524
|
+
// meant "never mind." @inquirer/prompts doesn't export the error
|
|
525
|
+
// class for an instanceof check, so this is the documented way to
|
|
526
|
+
// recognize it: by name.
|
|
527
|
+
if (err?.name === 'ExitPromptError') {
|
|
528
|
+
console.log(pc.dim('\nCancelled.'))
|
|
529
|
+
process.exit(0)
|
|
530
|
+
}
|
|
531
|
+
console.error(err)
|
|
532
|
+
process.exitCode = 1
|
|
533
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import pc from 'picocolors'
|
|
4
|
+
import { resolveConfigFilePath, readConfigFile, DEFAULT_CONFIG_PATH } from './configFile.js'
|
|
5
|
+
|
|
6
|
+
// No hardcoded fallback path — a folder that makes sense on one person's
|
|
7
|
+
// machine (an old default here used to be a Windows-only path) is either
|
|
8
|
+
// wrong or actively misleading for everyone else. An empty config just
|
|
9
|
+
// means `discoverRepos` finds nothing, and every command already prints
|
|
10
|
+
// "No repos found." for that — the hint below is what actually points
|
|
11
|
+
// people at the fix.
|
|
12
|
+
const EMPTY_CONFIG = { roots: [], packages: [] }
|
|
13
|
+
|
|
14
|
+
// Config shape:
|
|
15
|
+
// {
|
|
16
|
+
// "roots": ["/path/to/repos-folder"], // each entry: a folder whose
|
|
17
|
+
// // direct subdirectories are packages
|
|
18
|
+
// "packages": ["/path/to/one-off-repo"] // each entry: a single package folder itself
|
|
19
|
+
// }
|
|
20
|
+
// Both keys are optional and additive. Relative paths are resolved against
|
|
21
|
+
// the config file's own directory, not the current working directory —
|
|
22
|
+
// so the config stays correct no matter where `polyrepo` is invoked from
|
|
23
|
+
// (important once it's `npm link`-ed globally). Edit this file by hand or
|
|
24
|
+
// through `polyrepo setup`.
|
|
25
|
+
export function loadConfig({ configPath } = {}) {
|
|
26
|
+
const resolvedPath = resolveConfigFilePath(configPath)
|
|
27
|
+
|
|
28
|
+
let raw = EMPTY_CONFIG
|
|
29
|
+
let baseDir = path.dirname(DEFAULT_CONFIG_PATH)
|
|
30
|
+
|
|
31
|
+
if (fs.existsSync(resolvedPath)) {
|
|
32
|
+
baseDir = path.dirname(resolvedPath)
|
|
33
|
+
try {
|
|
34
|
+
raw = readConfigFile(resolvedPath)
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.log(pc.red(`Could not read config at ${resolvedPath}: ${err.message}`))
|
|
37
|
+
raw = EMPTY_CONFIG
|
|
38
|
+
baseDir = path.dirname(DEFAULT_CONFIG_PATH)
|
|
39
|
+
}
|
|
40
|
+
} else if (configPath || process.env.POLYREPO_CONFIG) {
|
|
41
|
+
// An explicit --config / POLYREPO_CONFIG path was given but doesn't exist —
|
|
42
|
+
// that's almost certainly a typo, worth a loud warning rather than a
|
|
43
|
+
// silent fallback.
|
|
44
|
+
console.log(pc.red(`Config file not found: ${resolvedPath}`))
|
|
45
|
+
} else {
|
|
46
|
+
console.log(pc.yellow(`No config found at ${resolvedPath} — run \`polyrepo setup\` to add package directories.`))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// POLYREPO_ROOT is a lighter-weight override for a single one-off run — it
|
|
50
|
+
// replaces the configured roots but leaves any explicit `packages` entries
|
|
51
|
+
// from the config file in place.
|
|
52
|
+
const roots = process.env.POLYREPO_ROOT ? [process.env.POLYREPO_ROOT] : raw.roots
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
configPath: resolvedPath,
|
|
56
|
+
roots: roots.map((p) => resolveAgainst(baseDir, p)),
|
|
57
|
+
packages: raw.packages.map((p) => resolveAgainst(baseDir, p)),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resolveAgainst(baseDir, p) {
|
|
62
|
+
return path.isAbsolute(p) ? p : path.resolve(baseDir, p)
|
|
63
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { MASTER_BRANCH } from './config.js'
|
|
2
|
+
import { git } from './exec.js'
|
|
3
|
+
|
|
4
|
+
// fetch → checkout master → fast-forward-only merge. Always run for real
|
|
5
|
+
// (never skipped under --dry-run) since it's read-only/reversible and
|
|
6
|
+
// downstream logic needs an accurate picture of where master actually is.
|
|
7
|
+
export function syncMaster(repo) {
|
|
8
|
+
if (!git(repo.path, ['fetch', 'origin']).ok) {
|
|
9
|
+
return { ok: false, message: 'git fetch origin failed.' }
|
|
10
|
+
}
|
|
11
|
+
if (!git(repo.path, ['checkout', MASTER_BRANCH]).ok) {
|
|
12
|
+
return { ok: false, message: `git checkout ${MASTER_BRANCH} failed.` }
|
|
13
|
+
}
|
|
14
|
+
if (!git(repo.path, ['merge', '--ff-only', `origin/${MASTER_BRANCH}`]).ok) {
|
|
15
|
+
return { ok: false, message: `Local ${MASTER_BRANCH} has diverged from origin — resolve manually.` }
|
|
16
|
+
}
|
|
17
|
+
return { ok: true }
|
|
18
|
+
}
|
package/src/pMap.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Minimal concurrency-limited parallel map — no extra dependency needed for
|
|
2
|
+
// the one thing this CLI uses it for: fanning a read-only check (registry
|
|
3
|
+
// lookup, `git log`, branch/status) out across many repos at once instead
|
|
4
|
+
// of waiting on each one in turn. Order of results matches input order,
|
|
5
|
+
// regardless of which finishes first.
|
|
6
|
+
export async function pMap(items, mapper, concurrency = 8) {
|
|
7
|
+
const results = new Array(items.length)
|
|
8
|
+
let nextIndex = 0
|
|
9
|
+
|
|
10
|
+
async function worker() {
|
|
11
|
+
while (nextIndex < items.length) {
|
|
12
|
+
const index = nextIndex++
|
|
13
|
+
results[index] = await mapper(items[index], index)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
|
|
18
|
+
await Promise.all(workers)
|
|
19
|
+
return results
|
|
20
|
+
}
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { npmAsync } from './exec.js'
|
|
2
|
+
|
|
3
|
+
// Read-only registry lookup — `npm view <name> version`. Returns null both
|
|
4
|
+
// for "never published" (npm exits with E404) and for any other lookup
|
|
5
|
+
// failure (offline, registry hiccup); either way the safe default is to
|
|
6
|
+
// treat the package as needing a look before publishing.
|
|
7
|
+
export async function fetchPublishedVersionAsync(repo) {
|
|
8
|
+
const result = await npmAsync(repo.path, ['view', repo.name, 'version'])
|
|
9
|
+
return result.ok ? result.stdout || null : null
|
|
10
|
+
}
|
package/src/release.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { gh, ghAsync } from './exec.js'
|
|
5
|
+
|
|
6
|
+
export async function releaseExistsAsync(repo, tag) {
|
|
7
|
+
const result = await ghAsync(repo.path, ['release', 'view', tag])
|
|
8
|
+
return result.ok
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// `--verify-tag` makes this fail loudly instead of inventing a new tag if
|
|
12
|
+
// the one we expect (from `polyrepo bump`) somehow isn't on origin yet — a
|
|
13
|
+
// release should only ever point at a tag that already exists. Notes come
|
|
14
|
+
// from the matching CHANGELOG.md section when there is one (see
|
|
15
|
+
// changelog.js's extractChangelogSection), otherwise gh's own
|
|
16
|
+
// --generate-notes (from merged PRs/commits) is the fallback.
|
|
17
|
+
export function createRelease(repo, { tag, title, notes, dryRun }) {
|
|
18
|
+
const args = ['release', 'create', tag, '--verify-tag', '--title', title]
|
|
19
|
+
let tempFile
|
|
20
|
+
if (notes) {
|
|
21
|
+
tempFile = path.join(os.tmpdir(), `polyrepo-release-notes-${process.pid}-${Date.now()}.md`)
|
|
22
|
+
fs.writeFileSync(tempFile, notes)
|
|
23
|
+
args.push('--notes-file', tempFile)
|
|
24
|
+
} else {
|
|
25
|
+
args.push('--generate-notes')
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return gh(repo.path, args, { mutating: true, dryRun })
|
|
29
|
+
} finally {
|
|
30
|
+
if (tempFile) fs.rmSync(tempFile, { force: true })
|
|
31
|
+
}
|
|
32
|
+
}
|