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