ReviewOS

also looking at this

stacks/buddy

feat(cli): restore the affordances the docs promised

#1402
Merged glennmichael123 wants to merge feat/restore-documented-affordances into main
6 files +150 -8
bin/cli.tsmodified+31-5
Changes to bin/cli.ts
@@ -289,10 +289,15 @@ interface CLIOptions {
289289 ignore?: string
290290 dryRun?: boolean
291291 respectLatest?: boolean
292 /** Which settings pages `open-settings` should open: repo, org or both. */
293 type?: string
292294}
293295
294296cli
295297 .command('setup', '🚀 Interactive setup for automated dependency updates (recommended)')
298 // `init` is what people type first, by convention from npm, git and cargo.
299 // Setup is the command that writes the config, so the alias is honest.
300 .alias('init')
296301 .option('--verbose, -v', 'Enable verbose logging')
297302 .option('--non-interactive', 'Run setup without prompts (use defaults)')
298303 .option('--preset <type>', 'Workflow preset: standard|high-frequency|security|minimal|testing', { default: 'standard' })
@@ -2633,10 +2638,19 @@ cli
26332638
26342639cli
26352640 .command('open-settings', 'Open GitHub repository and organization settings pages')
2641 .option('--type <scope>', 'Which settings to open: repo, org or both', { default: 'both' })
26362642 .option('--verbose, -v', 'Enable verbose logging')
26372643 .example('buddy open-settings')
26382644 .action(async (options: CLIOptions) => {
26392645 const logger = options.verbose ? Logger.verbose() : Logger.quiet()
2646
2647 // Validated before any work or output, so a typo fails immediately
2648 // rather than after the repository lookup has already printed.
2649 const scope = String(options?.type ?? 'both').toLowerCase()
2650 if (!['repo', 'org', 'both'].includes(scope)) {
2651 logger.error(`Unknown --type ${scope}. Expected repo, org or both.`)
2652 process.exit(1)
2653 }
26402654 const config = await resolveConfig(options.config)
26412655
26422656 try {
@@ -2708,13 +2722,16 @@ cli
27082722 }
27092723 }
27102724
2711 // Open repository settings
2712 await openUrl(repoUrl, 'repository settings')
2725 if (scope !== 'org')
2726 await openUrl(repoUrl, 'repository settings')
27132727
2714 // Also try to open organization settings (may fail if not an org)
2728 // Organisation settings 404 on a personal account, so they are opened
2729 // only when asked for, or as the tail of the default `both`.
27152730 setTimeout(async () => {
2716 logger.info('\n🏢 Attempting to open organization settings...')
2717 await openUrl(orgUrl, 'organization settings')
2731 if (scope !== 'repo') {
2732 logger.info('\n🏢 Attempting to open organization settings...')
2733 await openUrl(orgUrl, 'organization settings')
2734 }
27182735
27192736 logger.info('\n📋 Required Settings:')
27202737 logger.info(' 1. Under "Workflow permissions":')
@@ -3393,6 +3410,15 @@ cli.command('version', 'Show the version of Buddy').action(() => {
33933410 console.log(version)
33943411})
33953412
3413// `--help` is a flag, but `buddy help` is what a newcomer types. Registering
3414// it means the guess works rather than printing 'Command "help" not found'.
3415cli.command('help', 'Show this help text').action(() => {
3416 // Without this, outputHelp() renders the matched command — `buddy help`
3417 // itself — rather than the list of commands the reader is asking for.
3418 cli.unsetMatchedCommand()
3419 cli.outputHelp()
3420})
3421
33963422// Available on every command: each action reads `options.config` and passes it
33973423// to resolveConfig, which bypasses discovery when a path is given.
33983424cli.option('--config <path>', 'Path to a buddy config file')
docs/cli/overview.mdmodified+2-1
Changes to docs/cli/overview.md
@@ -271,8 +271,9 @@ done
271271### Built-in Help
272272
273273```bash
274# General help
274# General help — the flag and the subcommand do the same thing
275275buddy --help
276buddy help
276277
277278# Command-specific help
278279buddy scan --help
docs/cli/setup.mdmodified+6-0
Changes to docs/cli/setup.md
@@ -10,12 +10,18 @@ The `setup` command provides a comprehensive, Renovate-like setup experience tha
1010buddy setup [options]
1111```
1212
13`buddy init` is an alias for the same command, for the muscle memory built by
14`npm init` and `git init`.
15
1316## Quick Start
1417
1518```bash
1619# Interactive setup (recommended)
1720buddy setup
1821
22# The same thing, by its alias
23buddy init
24
1925# Setup with verbose logging
2026buddy setup --verbose
2127
docs/cli/utility.mdmodified+10-1
Changes to docs/cli/utility.md
@@ -12,12 +12,15 @@ buddy open-settings [options]
1212
1313### Description
1414
15Quickly open the GitHub settings pages needed to configure permissions for buddy. It opens the repository's Actions settings, follows with the organization's, and prints the exact options to change on each.
15Quickly open the GitHub settings pages needed to configure permissions for buddy. By default it opens the repository's Actions settings, follows with the organization's, and prints the exact options to change on each.
16
17Organization settings do not exist for a repository owned by a personal account, so `--type repo` is the quieter choice there.
1618
1719### Options
1820
1921| Option | Description | Default |
2022|--------|-------------|---------|
23| `--type <scope>` | Which settings to open: `repo`, `org` or `both` | `both` |
2124| `--verbose, -v` | Enable verbose logging | `false` |
2225
2326### Examples
@@ -26,6 +29,12 @@ Quickly open the GitHub settings pages needed to configure permissions for buddy
2629# Open the Actions settings pages for this repository
2730buddy open-settings
2831
32# Only this repository's settings, skipping the organization
33buddy open-settings --type repo
34
35# Only the organization's, when repository permissions are already correct
36buddy open-settings --type org
37
2938# Show how the repository was resolved
3039buddy open-settings --verbose
3140```
scripts/check-docs-claims.tsmodified+25-1
Changes to scripts/check-docs-claims.ts
@@ -65,10 +65,13 @@ const PROSE_AFTER_BUDDY = new Set([
6565async function readCliSurface(): Promise<Map<string, Set<string>>> {
6666 const lines = (await Bun.file(`${ROOT}bin/cli.ts`).text()).split('\n')
6767 const commands = new Map<string, Set<string>>()
68 const aliases = new Map<string, string>()
6869 const marks: Array<{ line: number, name: string }> = []
6970
7071 lines.forEach((line, i) => {
71 const match = line.match(/^\s*\.command\((["'])(.+?)\1\s*,/)
72 // Both spellings are in use: chained onto `cli`, and the single-line
73 // `cli.command('version', …).action(…)` form.
74 const match = line.match(/^\s*(?:cli)?\.command\((["'])(.+?)\1\s*,/)
7275 if (!match)
7376 return
7477
@@ -78,6 +81,21 @@ async function readCliSurface(): Promise<Map<string, Set<string>>> {
7881 commands.set(name, new Set(GLOBAL_FLAGS))
7982 })
8083
84 // An alias is a real name a user can type, so it must resolve like one.
85 lines.forEach((line, i) => {
86 const match = line.match(/^\s*\.alias\((["'])(.+?)\1\)/)
87 if (!match)
88 return
89
90 let owner: string | null = null
91 for (const mark of marks) {
92 if (mark.line < i)
93 owner = mark.name
94 }
95 if (owner)
96 aliases.set(match[2], owner)
97 })
98
8199 lines.forEach((line, i) => {
82100 const match = line.match(/^\s*\.option\((["'])(.+?)\1\s*,/)
83101 if (!match)
@@ -96,6 +114,12 @@ async function readCliSurface(): Promise<Map<string, Set<string>>> {
96114 commands.get(owner)!.add(token)
97115 })
98116
117 for (const [alias, target] of aliases) {
118 const flags = commands.get(target)
119 if (flags)
120 commands.set(alias, flags)
121 }
122
99123 return commands
100124}
101125
test/cli-surface.test.tsadded+76-0
Changes to test/cli-surface.test.ts
@@ -0,0 +1,76 @@
1import { describe, expect, it } from 'bun:test'
2
3/**
4 * The CLI is registered in `bin/cli.ts`, which calls `cli.parse()` at import,
5 * so it cannot be imported and inspected. These run it as a subprocess
6 * instead — the only way to assert on the surface a user actually meets.
7 *
8 * Kept to commands that neither reach the network nor touch the working tree.
9 */
10const CLI = new URL('../bin/cli.ts', import.meta.url).pathname
11
12/**
13 * Run the CLI and capture what a user would see.
14 *
15 * @param args - Arguments to pass
16 * @returns Exit code and combined output
17 */
18async function run(args: string[]): Promise<{ code: number, output: string }> {
19 const proc = Bun.spawn(['bun', CLI, ...args], {
20 stdout: 'pipe',
21 stderr: 'pipe',
22 env: { ...process.env, APP_ENV: 'test' },
23 })
24
25 const [stdout, stderr] = await Promise.all([
26 new Response(proc.stdout).text(),
27 new Response(proc.stderr).text(),
28 ])
29
30 return { code: await proc.exited, output: stdout + stderr }
31}
32
33describe('CLI surface', () => {
34 it('success case - `buddy help` lists the commands', async () => {
35 // A subcommand as well as a flag: `buddy help` is what a newcomer types,
36 // and it used to print 'Command "help" not found'.
37 const { code, output } = await run(['help'])
38
39 expect(code).toBe(0)
40 expect(output).toContain('buddy [command] [options]')
41 expect(output).toContain('review')
42 expect(output).toContain('scan')
43 // The command's own usage line would mean the global help was not shown.
44 expect(output).not.toContain('$ buddy help')
45 }, 30000)
46
47 it('success case - `buddy init` resolves to setup', async () => {
48 const { output } = await run(['init', '--help'])
49
50 expect(output).toContain('buddy setup')
51 }, 30000)
52
53 it('failure case - an unknown command is rejected', async () => {
54 const { output } = await run(['frobnicate'])
55
56 expect(output).toMatch(/not found/i)
57 }, 30000)
58
59 it('failure case - open-settings rejects an unknown --type', async () => {
60 // Rejected before the repository lookup, so the error is the first thing
61 // printed rather than the last.
62 const { code, output } = await run(['open-settings', '--type', 'bogus'])
63
64 expect(code).toBe(1)
65 expect(output).toContain('Expected repo, org or both')
66 expect(output).not.toContain('Opening GitHub Actions settings')
67 }, 30000)
68
69 it('failure case - an unknown flag is rejected rather than ignored', async () => {
70 // clapp hard-errors on unknown options, which is why a documented-but-
71 // unimplemented flag crashed rather than degrading.
72 const { output } = await run(['scan', '--totally-fake-flag'])
73
74 expect(output).toMatch(/unknown option/i)
75 }, 30000)
76})