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 { MASTER_BRANCH } from '../config.js'
|
|
5
|
+
import { loadConfig } from '../loadConfig.js'
|
|
6
|
+
import { syncMaster } from '../masterSync.js'
|
|
7
|
+
import { selectPackages } from '../selectPackages.js'
|
|
8
|
+
import { heading, stepHeading, ok, fail, warn, columnWidths, formatRow } from '../ui.js'
|
|
9
|
+
import { startSpinner } from '../spinner.js'
|
|
10
|
+
|
|
11
|
+
export async function switchMasterCommand({ configPath, packages, yes = false } = {}) {
|
|
12
|
+
const config = loadConfig({ configPath })
|
|
13
|
+
const discovered = discoverRepos(config)
|
|
14
|
+
if (discovered.length === 0) {
|
|
15
|
+
console.log(pc.yellow('No repos found.'))
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
heading(`Switch to ${MASTER_BRANCH}`)
|
|
20
|
+
|
|
21
|
+
const spinner = startSpinner(`Checking ${discovered.length} package(s)...`)
|
|
22
|
+
const repos = await inspectRepos(discovered)
|
|
23
|
+
spinner.stop()
|
|
24
|
+
|
|
25
|
+
const selected = await selectPackages({
|
|
26
|
+
items: repos,
|
|
27
|
+
packages,
|
|
28
|
+
message: 'Pick repos to switch to master and update:',
|
|
29
|
+
buildChoice: (all) => {
|
|
30
|
+
const columns = [{ value: (r) => r.dir }]
|
|
31
|
+
const widths = columnWidths(all, columns)
|
|
32
|
+
return (r) => ({
|
|
33
|
+
name:
|
|
34
|
+
formatRow(r, columns, widths) +
|
|
35
|
+
pc.dim(` (currently on: ${r.branch ?? '(detached)'}${r.clean ? '' : ', dirty'})`),
|
|
36
|
+
value: r,
|
|
37
|
+
checked: r.branch !== MASTER_BRANCH,
|
|
38
|
+
})
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
if (selected.length === 0) {
|
|
43
|
+
console.log(pc.dim('Nothing selected.'))
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!yes) {
|
|
48
|
+
const proceed = await confirm({
|
|
49
|
+
message: `Switch ${selected.length} repo(s) to ${MASTER_BRANCH} and fast-forward?`,
|
|
50
|
+
default: true,
|
|
51
|
+
})
|
|
52
|
+
if (!proceed) {
|
|
53
|
+
console.log(pc.dim('Cancelled.'))
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let index = 0
|
|
59
|
+
for (const repo of selected) {
|
|
60
|
+
index += 1
|
|
61
|
+
stepHeading(index, selected.length, repo.dir)
|
|
62
|
+
|
|
63
|
+
if (!repo.clean) {
|
|
64
|
+
warn(`Working tree is dirty — skipping to avoid discarding local changes.`)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const result = syncMaster(repo)
|
|
69
|
+
if (!result.ok) {
|
|
70
|
+
fail(result.message)
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
ok(`Now on ${MASTER_BRANCH}, up to date with origin.`)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
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 { syncMaster } from '../masterSync.js'
|
|
6
|
+
import { tagName, tagExists, tagExistsAsync, createAndPushTag } from '../tags.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
|
+
import { releaseOne } from './release.js'
|
|
11
|
+
|
|
12
|
+
// For packages whose version was bumped outside `polyrepo bump` (or before `bump`
|
|
13
|
+
// started tagging), there's no `v<version>` tag yet — `polyrepo release` refuses
|
|
14
|
+
// to touch those. This puts just the tag on the current version, on
|
|
15
|
+
// master's current tip, without touching the version number or opening a
|
|
16
|
+
// PR — no need to bump again just to get a tag. Offers to release
|
|
17
|
+
// right after, since "I just caught this package's tag up" and "I want a
|
|
18
|
+
// release for it" are almost always the same reason to run this.
|
|
19
|
+
export async function tagCommand({ configPath, packages, yes = false, dryRun = false, release = false } = {}) {
|
|
20
|
+
const config = loadConfig({ configPath })
|
|
21
|
+
const repos = (await inspectRepos(discoverRepos(config))).filter((r) => r.version)
|
|
22
|
+
if (repos.length === 0) {
|
|
23
|
+
console.log(pc.yellow('No repos found.'))
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
heading('Tag current versions')
|
|
28
|
+
|
|
29
|
+
console.log(pc.dim(`Checking ${repos.length} package(s) for an existing tag...`))
|
|
30
|
+
const withTag = await pMap(repos, async (r) => {
|
|
31
|
+
const tag = tagName(r.version)
|
|
32
|
+
const alreadyTagged = await tagExistsAsync(r, tag)
|
|
33
|
+
console.log(pc.dim(` ${r.dir}: ${tag} — ${alreadyTagged ? 'already tagged' : 'not tagged yet'}`))
|
|
34
|
+
return { ...r, tag, alreadyTagged }
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const selected = await selectPackages({
|
|
38
|
+
items: withTag,
|
|
39
|
+
packages,
|
|
40
|
+
message: 'Pick packages to tag at their current version:',
|
|
41
|
+
buildChoice: (all) => {
|
|
42
|
+
const columns = [
|
|
43
|
+
{ value: (r) => r.dir },
|
|
44
|
+
{ value: (r) => r.version, style: (r, t) => pc.dim(t) },
|
|
45
|
+
{ value: (r) => r.tag, style: (r, t) => pc.dim(t) },
|
|
46
|
+
]
|
|
47
|
+
const widths = columnWidths(all, columns)
|
|
48
|
+
return (r) => ({
|
|
49
|
+
name: formatRow(r, columns, widths) + (r.alreadyTagged ? pc.dim(' (already tagged)') : ''),
|
|
50
|
+
value: r,
|
|
51
|
+
checked: !r.alreadyTagged,
|
|
52
|
+
})
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
if (selected.length === 0) {
|
|
57
|
+
console.log(pc.dim('Nothing selected.'))
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!yes) {
|
|
62
|
+
const proceed = await confirm({
|
|
63
|
+
message: `Tag ${selected.length} package(s) at their current version?${dryRun ? ' (dry run — nothing will actually be pushed)' : ''}`,
|
|
64
|
+
default: true,
|
|
65
|
+
})
|
|
66
|
+
if (!proceed) {
|
|
67
|
+
console.log(pc.dim('Cancelled.'))
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ready = []
|
|
73
|
+
let index = 0
|
|
74
|
+
for (const repo of selected) {
|
|
75
|
+
index += 1
|
|
76
|
+
stepHeading(index, selected.length, `${repo.dir} ${repo.tag}`)
|
|
77
|
+
|
|
78
|
+
const syncResult = syncMaster(repo)
|
|
79
|
+
if (!syncResult.ok) {
|
|
80
|
+
fail(syncResult.message)
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
ok('master is up to date.')
|
|
84
|
+
|
|
85
|
+
// Re-checked here (not just trusting the table above) in case it
|
|
86
|
+
// changed between listing and now — same reasoning as bump's
|
|
87
|
+
// detectBumpState re-check right before acting.
|
|
88
|
+
if (tagExists(repo, repo.tag)) {
|
|
89
|
+
ok(`Tag ${repo.tag} already exists on origin.`)
|
|
90
|
+
ready.push(repo)
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const tagResult = createAndPushTag(repo, repo.tag, { dryRun })
|
|
95
|
+
if (!tagResult.ok) {
|
|
96
|
+
fail(tagResult.message)
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
ok(`Tagged and pushed ${repo.tag}.`)
|
|
100
|
+
if (!dryRun) ready.push(repo)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (dryRun || ready.length === 0) return
|
|
104
|
+
|
|
105
|
+
let wantRelease = release
|
|
106
|
+
if (!wantRelease && !yes) {
|
|
107
|
+
wantRelease = await confirm({
|
|
108
|
+
message: `Create a GitHub Release for the ${ready.length} package(s) just tagged?`,
|
|
109
|
+
default: true,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
if (!wantRelease) return
|
|
113
|
+
|
|
114
|
+
let releaseIndex = 0
|
|
115
|
+
for (const repo of ready) {
|
|
116
|
+
releaseIndex += 1
|
|
117
|
+
stepHeading(releaseIndex, ready.length, `${repo.dir} ${repo.tag}`)
|
|
118
|
+
releaseOne(repo, repo.tag, { dryRun })
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
6
|
+
const PROJECT_ROOT = path.join(__dirname, '..')
|
|
7
|
+
export const DEFAULT_CONFIG_PATH = path.join(PROJECT_ROOT, 'polyrepo.config.json')
|
|
8
|
+
|
|
9
|
+
// Same --config / POLYREPO_CONFIG / default resolution `loadConfig` uses, split
|
|
10
|
+
// out so `polyrepo setup` can find "the file to edit" without also pulling in
|
|
11
|
+
// loadConfig's read-side behavior (POLYREPO_ROOT override, resolving every path
|
|
12
|
+
// to absolute) — setup edits the file's own raw, possibly-relative paths.
|
|
13
|
+
export function resolveConfigFilePath(configPath) {
|
|
14
|
+
if (configPath) return path.resolve(configPath)
|
|
15
|
+
if (process.env.POLYREPO_CONFIG) return path.resolve(process.env.POLYREPO_CONFIG)
|
|
16
|
+
return DEFAULT_CONFIG_PATH
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Raw (possibly relative, exactly as a person typed them) roots/packages —
|
|
20
|
+
// no existence checks, no POLYREPO_ROOT override. Returns an empty config if the
|
|
21
|
+
// file doesn't exist yet, so `polyrepo setup` can start from scratch at a new
|
|
22
|
+
// --config path instead of erroring.
|
|
23
|
+
export function readConfigFile(filePath) {
|
|
24
|
+
if (!fs.existsSync(filePath)) return { roots: [], packages: [] }
|
|
25
|
+
const text = fs.readFileSync(filePath, 'utf8')
|
|
26
|
+
const parsed = JSON.parse(text)
|
|
27
|
+
return {
|
|
28
|
+
roots: Array.isArray(parsed.roots) ? parsed.roots : [],
|
|
29
|
+
packages: Array.isArray(parsed.packages) ? parsed.packages : [],
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function writeConfigFile(filePath, data) {
|
|
34
|
+
const text = JSON.stringify({ roots: data.roots, packages: data.packages }, null, 2) + '\n'
|
|
35
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
36
|
+
fs.writeFileSync(filePath, text)
|
|
37
|
+
}
|
package/src/crossDeps.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import semver from 'semver'
|
|
2
|
+
import { readPackageDependencies } from './repos.js'
|
|
3
|
+
|
|
4
|
+
// After bumping a package, other local packages that depend on it (e.g.
|
|
5
|
+
// css-magic-gradient depends on color-value-tools) can be left pointing at
|
|
6
|
+
// a version range that no longer matches — nothing enforces that today, so
|
|
7
|
+
// it's easy to bump one package and forget the ones that reference it.
|
|
8
|
+
// `repos` must already be inspected (name + version populated).
|
|
9
|
+
export function findStaleLocalDeps(repos) {
|
|
10
|
+
const versionByName = new Map(repos.map((r) => [r.name, r.version]))
|
|
11
|
+
const issues = []
|
|
12
|
+
|
|
13
|
+
for (const repo of repos) {
|
|
14
|
+
const deps = readPackageDependencies(repo)
|
|
15
|
+
for (const [depName, range] of Object.entries(deps)) {
|
|
16
|
+
if (depName === repo.name || !versionByName.has(depName)) continue
|
|
17
|
+
// Not every dependency value is a semver range — "workspace:*"
|
|
18
|
+
// (yarn/pnpm workspaces), "file:../x", "link:../y", and "npm:pkg@range"
|
|
19
|
+
// aliases are all common and none of them are something to compare a
|
|
20
|
+
// version against. semver.satisfies() doesn't throw for these (it
|
|
21
|
+
// just quietly returns false), so it can't be used to detect them —
|
|
22
|
+
// validRange() is the actual check.
|
|
23
|
+
if (!semver.validRange(range)) continue
|
|
24
|
+
const localVersion = versionByName.get(depName)
|
|
25
|
+
if (!semver.satisfies(localVersion, range)) {
|
|
26
|
+
issues.push({ repo, depName, range, localVersion })
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return issues
|
|
31
|
+
}
|
package/src/exec.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { spawnSync, spawn } from 'node:child_process'
|
|
2
|
+
import pc from 'picocolors'
|
|
3
|
+
|
|
4
|
+
// Runs a command, always printing what ran and its output so the caller
|
|
5
|
+
// never has to wait for a whole queue to finish before seeing progress —
|
|
6
|
+
// each command reports itself the moment it's done.
|
|
7
|
+
//
|
|
8
|
+
// `mutating: true` marks a command that changes remote/shared state (push,
|
|
9
|
+
// commit, gh pr create/merge) — under --dry-run those are printed but never
|
|
10
|
+
// actually executed, while read-only commands (fetch, checkout, status)
|
|
11
|
+
// still run for real so the printed state stays accurate.
|
|
12
|
+
//
|
|
13
|
+
// `interactive: true` hands the command the real terminal (stdio: 'inherit')
|
|
14
|
+
// instead of capturing it — needed for `npm publish`, which can stop and
|
|
15
|
+
// wait on a real TTY for a 2FA/OTP code. Capturing its output would make
|
|
16
|
+
// that prompt invisible and leave the process hanging forever.
|
|
17
|
+
// On Windows, `npm` (and anything else that resolves to a .cmd/.bat shim
|
|
18
|
+
// instead of a real .exe) can't be spawned directly — CreateProcess doesn't
|
|
19
|
+
// know what to do with a batch file, so spawnSync silently fails to launch
|
|
20
|
+
// it at all (`status` comes back null) unless the shell is involved. `git`
|
|
21
|
+
// and `gh` are real executables and don't need this, but turning it on
|
|
22
|
+
// unconditionally on Windows is the standard fix and is safe here since
|
|
23
|
+
// every arg still goes through spawnSync's own array (each element quoted
|
|
24
|
+
// for the shell), not a hand-built command string.
|
|
25
|
+
const NEEDS_SHELL = process.platform === 'win32'
|
|
26
|
+
|
|
27
|
+
export function run(cwd, cmd, args, { quiet = false, mutating = false, dryRun = false, interactive = false } = {}) {
|
|
28
|
+
const prefix = mutating && dryRun ? pc.magenta(' [dry-run] $ ') : pc.dim(' $ ')
|
|
29
|
+
if (!quiet) console.log(`${prefix}${cmd} ${args.join(' ')}`)
|
|
30
|
+
if (mutating && dryRun) return { ok: true, stdout: '', stderr: '', status: 0, skipped: true }
|
|
31
|
+
|
|
32
|
+
if (interactive) {
|
|
33
|
+
const result = spawnSync(cmd, args, { cwd, stdio: 'inherit', shell: NEEDS_SHELL })
|
|
34
|
+
return { ok: result.status === 0, stdout: '', stderr: '', status: result.status }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const result = spawnSync(cmd, args, { cwd, encoding: 'utf8', shell: NEEDS_SHELL })
|
|
38
|
+
const stdout = (result.stdout || '').trim()
|
|
39
|
+
// If the process couldn't even be spawned (bad cwd, command not found),
|
|
40
|
+
// result.error is set and stdout/stderr never got produced — surface that
|
|
41
|
+
// instead of silently reporting an empty failure.
|
|
42
|
+
const stderr = (result.stderr || '').trim() || (result.error ? result.error.message : '')
|
|
43
|
+
if (!quiet) {
|
|
44
|
+
if (stdout) console.log(indent(stdout))
|
|
45
|
+
if (stderr) console.log(indent(stderr))
|
|
46
|
+
}
|
|
47
|
+
const ok = result.status === 0
|
|
48
|
+
return { ok, stdout, stderr, status: result.status }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ASYNC_TIMEOUT_MS = 30_000
|
|
52
|
+
|
|
53
|
+
// Async counterpart to `run()`, used only for read-only lookups that get
|
|
54
|
+
// fanned out across many repos at once (see pMap.js) — a registry check or
|
|
55
|
+
// `git log` for one repo doesn't need to wait for the same call on the
|
|
56
|
+
// other sixteen. Always quiet: interleaved "$ git ..." lines from several
|
|
57
|
+
// repos at once would be noise, not progress — callers report their own
|
|
58
|
+
// summary once the whole batch settles. Never used for mutating commands;
|
|
59
|
+
// those stay on the synchronous, one-at-a-time `run()` above so their
|
|
60
|
+
// progress prints in the order things actually happen.
|
|
61
|
+
//
|
|
62
|
+
// Guarded with a hard timeout: under real concurrency (several of these in
|
|
63
|
+
// flight at once — that's the whole point of using this over `run()`) a
|
|
64
|
+
// spawned process has occasionally been observed to just never emit
|
|
65
|
+
// 'close' at all (seen with `git ls-remote` specifically, intermittently,
|
|
66
|
+
// only when several ran concurrently — never reproduced running the exact
|
|
67
|
+
// same call alone, so this reads as a spawn/network edge case rather than
|
|
68
|
+
// anything wrong with the git invocation itself). Without a limit here,
|
|
69
|
+
// one wedged child process would hang the whole pMap batch — and every
|
|
70
|
+
// command built on it — forever, with the terminal just sitting there
|
|
71
|
+
// giving no indication why.
|
|
72
|
+
export function runAsync(cwd, cmd, args) {
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
let settled = false
|
|
75
|
+
const child = spawn(cmd, args, { cwd, shell: NEEDS_SHELL })
|
|
76
|
+
let stdout = ''
|
|
77
|
+
let stderr = ''
|
|
78
|
+
|
|
79
|
+
const timer = setTimeout(() => {
|
|
80
|
+
if (settled) return
|
|
81
|
+
settled = true
|
|
82
|
+
child.kill()
|
|
83
|
+
resolve({ ok: false, stdout: '', stderr: `timed out after ${ASYNC_TIMEOUT_MS / 1000}s`, status: null })
|
|
84
|
+
}, ASYNC_TIMEOUT_MS)
|
|
85
|
+
|
|
86
|
+
child.stdout?.on('data', (d) => (stdout += d))
|
|
87
|
+
child.stderr?.on('data', (d) => (stderr += d))
|
|
88
|
+
child.on('error', (err) => {
|
|
89
|
+
if (settled) return
|
|
90
|
+
settled = true
|
|
91
|
+
clearTimeout(timer)
|
|
92
|
+
resolve({ ok: false, stdout: '', stderr: err.message, status: null })
|
|
93
|
+
})
|
|
94
|
+
child.on('close', (status) => {
|
|
95
|
+
if (settled) return
|
|
96
|
+
settled = true
|
|
97
|
+
clearTimeout(timer)
|
|
98
|
+
resolve({ ok: status === 0, stdout: stdout.trim(), stderr: stderr.trim(), status })
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function gitAsync(cwd, args) {
|
|
104
|
+
return runAsync(cwd, 'git', args)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function ghAsync(cwd, args) {
|
|
108
|
+
return runAsync(cwd, 'gh', args)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function npmAsync(cwd, args) {
|
|
112
|
+
return runAsync(cwd, 'npm', args)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function indent(text) {
|
|
116
|
+
return text
|
|
117
|
+
.split('\n')
|
|
118
|
+
.map((line) => ` ${line}`)
|
|
119
|
+
.join('\n')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function git(cwd, args, opts) {
|
|
123
|
+
return run(cwd, 'git', args, opts)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function gh(cwd, args, opts) {
|
|
127
|
+
return run(cwd, 'gh', args, opts)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function npm(cwd, args, opts) {
|
|
131
|
+
return run(cwd, 'npm', args, opts)
|
|
132
|
+
}
|
package/src/export.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { columnWidths } from './ui.js'
|
|
4
|
+
|
|
5
|
+
// Reuses the exact columns/rows already computed for the terminal table —
|
|
6
|
+
// what gets saved to a file is always what you'd have seen on screen (same
|
|
7
|
+
// --quick/--path choices), not a separate export-only data shape.
|
|
8
|
+
function toRecords(rows, columns) {
|
|
9
|
+
return rows.map((row) => Object.fromEntries(columns.map((col) => [col.label, String(col.value(row))])))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// A cell that's exactly the checkmark/cross the terminal table uses for a
|
|
13
|
+
// plain yes/no fact (e.g. Release, or npm when it's up to date) becomes a
|
|
14
|
+
// real boolean here — JSON is for machines, "✓" isn't a value a JSON
|
|
15
|
+
// consumer can branch on without string-matching it back out. Everything
|
|
16
|
+
// else (an actual outdated registry version, "⚠ N" stale deps, "clean"/
|
|
17
|
+
// "dirty", "—" for not-applicable) carries more information than a plain
|
|
18
|
+
// yes/no and stays as text — collapsing those to booleans would throw
|
|
19
|
+
// real data away, not just reformat it.
|
|
20
|
+
function toJsonValue(text) {
|
|
21
|
+
if (text === '✓') return true
|
|
22
|
+
if (text === '✗') return false
|
|
23
|
+
return text
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatAsJson(rows, columns) {
|
|
27
|
+
const records = rows.map((row) =>
|
|
28
|
+
Object.fromEntries(columns.map((col) => [col.label, toJsonValue(String(col.value(row)))])),
|
|
29
|
+
)
|
|
30
|
+
return JSON.stringify(records, null, 2) + '\n'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatAsMarkdown(rows, columns) {
|
|
34
|
+
const headers = columns.map((c) => c.label)
|
|
35
|
+
const escape = (v) => v.replace(/\|/g, '\\|')
|
|
36
|
+
const records = toRecords(rows, columns)
|
|
37
|
+
const lines = [
|
|
38
|
+
`| ${headers.join(' | ')} |`,
|
|
39
|
+
`| ${headers.map(() => '---').join(' | ')} |`,
|
|
40
|
+
...records.map((r) => `| ${headers.map((h) => escape(r[h])).join(' | ')} |`),
|
|
41
|
+
]
|
|
42
|
+
return lines.join('\n') + '\n'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Same aligned-column layout `printTable` prints to the terminal, minus the
|
|
46
|
+
// ANSI color codes `col.style` would add — a plain-text file shouldn't be
|
|
47
|
+
// full of escape sequences.
|
|
48
|
+
function formatAsText(rows, columns) {
|
|
49
|
+
const widths = columnWidths(rows, columns)
|
|
50
|
+
const header = columns.map((col, i) => col.label.padEnd(widths[i])).join(' ')
|
|
51
|
+
const separator = widths.map((w) => '-'.repeat(w)).join(' ')
|
|
52
|
+
const lines = [header, separator, ...rows.map((row) => columns.map((col, i) => String(col.value(row)).padEnd(widths[i])).join(' '))]
|
|
53
|
+
return lines.join('\n') + '\n'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// RFC 4180: a field only needs quoting when it contains a comma, a quote,
|
|
57
|
+
// or a newline — quoting everything unconditionally would still be valid
|
|
58
|
+
// CSV, but leaves the common case (plain version numbers, branch names)
|
|
59
|
+
// needlessly noisy to read.
|
|
60
|
+
function csvEscape(value) {
|
|
61
|
+
return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatAsCsv(rows, columns) {
|
|
65
|
+
const headers = columns.map((c) => c.label)
|
|
66
|
+
const records = toRecords(rows, columns)
|
|
67
|
+
const lines = [
|
|
68
|
+
headers.map(csvEscape).join(','),
|
|
69
|
+
...records.map((r) => headers.map((h) => csvEscape(r[h])).join(',')),
|
|
70
|
+
]
|
|
71
|
+
return lines.join('\n') + '\n'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function htmlEscape(value) {
|
|
75
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// A standalone document (not just a bare <table> fragment) so --output
|
|
79
|
+
// report.html is directly openable in a browser and still readable, not
|
|
80
|
+
// just an unstyled table dumped on a blank page.
|
|
81
|
+
function formatAsHtml(rows, columns) {
|
|
82
|
+
const headers = columns.map((c) => c.label)
|
|
83
|
+
const records = toRecords(rows, columns)
|
|
84
|
+
const theadCells = headers.map((h) => `<th>${htmlEscape(h)}</th>`).join('')
|
|
85
|
+
const bodyRows = records
|
|
86
|
+
.map((r) => ` <tr>${headers.map((h) => `<td>${htmlEscape(r[h])}</td>`).join('')}</tr>`)
|
|
87
|
+
.join('\n')
|
|
88
|
+
return `<!doctype html>
|
|
89
|
+
<html>
|
|
90
|
+
<head>
|
|
91
|
+
<meta charset="utf-8">
|
|
92
|
+
<title>polyrepo list</title>
|
|
93
|
+
<style>
|
|
94
|
+
table { border-collapse: collapse; font-family: monospace; }
|
|
95
|
+
th, td { border: 1px solid #ccc; padding: 4px 8px; text-align: left; }
|
|
96
|
+
th { background: #eee; }
|
|
97
|
+
</style>
|
|
98
|
+
</head>
|
|
99
|
+
<body>
|
|
100
|
+
<table>
|
|
101
|
+
<thead><tr>${theadCells}</tr></thead>
|
|
102
|
+
<tbody>
|
|
103
|
+
${bodyRows}
|
|
104
|
+
</tbody>
|
|
105
|
+
</table>
|
|
106
|
+
</body>
|
|
107
|
+
</html>
|
|
108
|
+
`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const FORMATTERS = { json: formatAsJson, md: formatAsMarkdown, csv: formatAsCsv, html: formatAsHtml, txt: formatAsText }
|
|
112
|
+
|
|
113
|
+
export const EXPORT_FORMATS = Object.keys(FORMATTERS)
|
|
114
|
+
|
|
115
|
+
export function exportTable(rows, columns, format) {
|
|
116
|
+
return FORMATTERS[format](rows, columns)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Guesses a format from the --output file's own extension, for when
|
|
120
|
+
// --format isn't given explicitly — .json/.md/.csv/.html are unambiguous,
|
|
121
|
+
// anything else (including no extension at all) falls back to plain text.
|
|
122
|
+
export function inferExportFormat(outputPath) {
|
|
123
|
+
const ext = path.extname(outputPath).toLowerCase()
|
|
124
|
+
if (ext === '.json') return 'json'
|
|
125
|
+
if (ext === '.md' || ext === '.markdown') return 'md'
|
|
126
|
+
if (ext === '.csv') return 'csv'
|
|
127
|
+
if (ext === '.html' || ext === '.htm') return 'html'
|
|
128
|
+
return 'txt'
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function writeExport(outputPath, content) {
|
|
132
|
+
const resolved = path.resolve(outputPath)
|
|
133
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true })
|
|
134
|
+
fs.writeFileSync(resolved, content)
|
|
135
|
+
return resolved
|
|
136
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import pc from 'picocolors'
|
|
2
|
+
|
|
3
|
+
// Same "--packages a,b,c" matching selectPackages.js uses to skip its
|
|
4
|
+
// checkbox, factored out for read-only commands (outdated, prs) that never
|
|
5
|
+
// show a checkbox at all — there's nothing to pick, just a report to
|
|
6
|
+
// narrow down. `names` undefined means "no filter, return everything".
|
|
7
|
+
export function filterByNames(items, names) {
|
|
8
|
+
if (!names) return items
|
|
9
|
+
const wanted = new Set(names.map((n) => n.trim().toLowerCase()).filter(Boolean))
|
|
10
|
+
const selected = items.filter((r) => wanted.has(r.dir.toLowerCase()))
|
|
11
|
+
const found = new Set(selected.map((r) => r.dir.toLowerCase()))
|
|
12
|
+
for (const name of wanted) {
|
|
13
|
+
if (!found.has(name)) console.log(pc.yellow(`Unknown package, ignoring: ${name}`))
|
|
14
|
+
}
|
|
15
|
+
return selected
|
|
16
|
+
}
|
package/src/github.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { gh, git } from './exec.js'
|
|
2
|
+
|
|
3
|
+
// Looks up an existing PR for `branch` in the given state, quietly (no
|
|
4
|
+
// command echo — this runs before we know whether there's anything
|
|
5
|
+
// interesting to report, so the caller decides what to print).
|
|
6
|
+
function findPr(repo, branch, state) {
|
|
7
|
+
const result = gh(repo.path, ['pr', 'list', '--head', branch, '--state', state, '--json', 'number,url'], {
|
|
8
|
+
quiet: true,
|
|
9
|
+
})
|
|
10
|
+
if (!result.ok || !result.stdout) return null
|
|
11
|
+
try {
|
|
12
|
+
const list = JSON.parse(result.stdout)
|
|
13
|
+
return list[0] ?? null
|
|
14
|
+
} catch {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Figures out where a previous (possibly interrupted) bump attempt for this
|
|
20
|
+
// exact branch left off, so a re-run resumes instead of failing on
|
|
21
|
+
// "branch already exists" or opening a duplicate PR:
|
|
22
|
+
// - 'merged' — the PR already landed; only a local master sync is left.
|
|
23
|
+
// - 'open' — a PR exists and just needs merging.
|
|
24
|
+
// - 'branch' — the branch was pushed but no PR was ever opened.
|
|
25
|
+
// - 'fresh' — nothing exists yet, do the full flow.
|
|
26
|
+
export function detectBumpState(repo, branch) {
|
|
27
|
+
const merged = findPr(repo, branch, 'merged')
|
|
28
|
+
if (merged) return { status: 'merged', pr: merged }
|
|
29
|
+
|
|
30
|
+
const open = findPr(repo, branch, 'open')
|
|
31
|
+
if (open) return { status: 'open', pr: open }
|
|
32
|
+
|
|
33
|
+
if (remoteBranchExists(repo, branch)) return { status: 'branch' }
|
|
34
|
+
|
|
35
|
+
return { status: 'fresh' }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function remoteBranchExists(repo, branch) {
|
|
39
|
+
const result = git(repo.path, ['ls-remote', '--exit-code', '--heads', 'origin', branch], { quiet: true })
|
|
40
|
+
return result.ok
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createPr(repo, { base, branch, title, body, dryRun }) {
|
|
44
|
+
const result = gh(
|
|
45
|
+
repo.path,
|
|
46
|
+
['pr', 'create', '--base', base, '--head', branch, '--title', title, '--body', body],
|
|
47
|
+
{ mutating: true, dryRun },
|
|
48
|
+
)
|
|
49
|
+
if (!result.ok) return { ok: false }
|
|
50
|
+
const match = result.stdout.match(/\/pull\/(\d+)/)
|
|
51
|
+
if (!match) return { ok: false, message: `Could not parse PR number from: ${result.stdout}` }
|
|
52
|
+
return { ok: true, number: match[1] }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function mergePr(repo, number, { dryRun } = {}) {
|
|
56
|
+
return gh(repo.path, ['pr', 'merge', number, '--merge', '--delete-branch=false'], { mutating: true, dryRun })
|
|
57
|
+
}
|