polyrepo-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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
+ }
@@ -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
+ }