also looking at this
feat(cli): restore the affordances the docs promised
#1402
6 files
+150
-8
| @@ -289,10 +289,15 @@ interface CLIOptions { | ||
| 289 | 289 | ignore?: string |
| 290 | 290 | dryRun?: boolean |
| 291 | 291 | respectLatest?: boolean |
| 292 | /** Which settings pages `open-settings` should open: repo, org or both. */ | |
| 293 | type?: string | |
| 292 | 294 | } |
| 293 | 295 | |
| 294 | 296 | cli |
| 295 | 297 | .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') | |
| 296 | 301 | .option('--verbose, -v', 'Enable verbose logging') |
| 297 | 302 | .option('--non-interactive', 'Run setup without prompts (use defaults)') |
| 298 | 303 | .option('--preset <type>', 'Workflow preset: standard|high-frequency|security|minimal|testing', { default: 'standard' }) |
| @@ -2633,10 +2638,19 @@ cli | ||
| 2633 | 2638 | |
| 2634 | 2639 | cli |
| 2635 | 2640 | .command('open-settings', 'Open GitHub repository and organization settings pages') |
| 2641 | .option('--type <scope>', 'Which settings to open: repo, org or both', { default: 'both' }) | |
| 2636 | 2642 | .option('--verbose, -v', 'Enable verbose logging') |
| 2637 | 2643 | .example('buddy open-settings') |
| 2638 | 2644 | .action(async (options: CLIOptions) => { |
| 2639 | 2645 | 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 | } | |
| 2640 | 2654 | const config = await resolveConfig(options.config) |
| 2641 | 2655 | |
| 2642 | 2656 | try { |
| @@ -2708,13 +2722,16 @@ cli | ||
| 2708 | 2722 | } |
| 2709 | 2723 | } |
| 2710 | 2724 | |
| 2711 | // Open repository settings | |
| 2712 | await openUrl(repoUrl, 'repository settings') | |
| 2725 | if (scope !== 'org') | |
| 2726 | await openUrl(repoUrl, 'repository settings') | |
| 2713 | 2727 | |
| 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`. | |
| 2715 | 2730 | 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 | } | |
| 2718 | 2735 | |
| 2719 | 2736 | logger.info('\n📋 Required Settings:') |
| 2720 | 2737 | logger.info(' 1. Under "Workflow permissions":') |
| @@ -3393,6 +3410,15 @@ cli.command('version', 'Show the version of Buddy').action(() => { | ||
| 3393 | 3410 | console.log(version) |
| 3394 | 3411 | }) |
| 3395 | 3412 | |
| 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'. | |
| 3415 | cli.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 | ||
| 3396 | 3422 | // Available on every command: each action reads `options.config` and passes it |
| 3397 | 3423 | // to resolveConfig, which bypasses discovery when a path is given. |
| 3398 | 3424 | cli.option('--config <path>', 'Path to a buddy config file') |
| @@ -10,12 +10,18 @@ The `setup` command provides a comprehensive, Renovate-like setup experience tha | ||
| 10 | 10 | buddy setup [options] |
| 11 | 11 | ``` |
| 12 | 12 | |
| 13 | `buddy init` is an alias for the same command, for the muscle memory built by | |
| 14 | `npm init` and `git init`. | |
| 15 | ||
| 13 | 16 | ## Quick Start |
| 14 | 17 | |
| 15 | 18 | ```bash |
| 16 | 19 | # Interactive setup (recommended) |
| 17 | 20 | buddy setup |
| 18 | 21 | |
| 22 | # The same thing, by its alias | |
| 23 | buddy init | |
| 24 | ||
| 19 | 25 | # Setup with verbose logging |
| 20 | 26 | buddy setup --verbose |
| 21 | 27 | |
| @@ -12,12 +12,15 @@ buddy open-settings [options] | ||
| 12 | 12 | |
| 13 | 13 | ### Description |
| 14 | 14 | |
| 15 | Quickly 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. | |
| 15 | Quickly 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 | ||
| 17 | Organization settings do not exist for a repository owned by a personal account, so `--type repo` is the quieter choice there. | |
| 16 | 18 | |
| 17 | 19 | ### Options |
| 18 | 20 | |
| 19 | 21 | | Option | Description | Default | |
| 20 | 22 | |--------|-------------|---------| |
| 23 | | `--type <scope>` | Which settings to open: `repo`, `org` or `both` | `both` | | |
| 21 | 24 | | `--verbose, -v` | Enable verbose logging | `false` | |
| 22 | 25 | |
| 23 | 26 | ### Examples |
| @@ -26,6 +29,12 @@ Quickly open the GitHub settings pages needed to configure permissions for buddy | ||
| 26 | 29 | # Open the Actions settings pages for this repository |
| 27 | 30 | buddy open-settings |
| 28 | 31 | |
| 32 | # Only this repository's settings, skipping the organization | |
| 33 | buddy open-settings --type repo | |
| 34 | ||
| 35 | # Only the organization's, when repository permissions are already correct | |
| 36 | buddy open-settings --type org | |
| 37 | ||
| 29 | 38 | # Show how the repository was resolved |
| 30 | 39 | buddy open-settings --verbose |
| 31 | 40 | ``` |
| @@ -65,10 +65,13 @@ const PROSE_AFTER_BUDDY = new Set([ | ||
| 65 | 65 | async function readCliSurface(): Promise<Map<string, Set<string>>> { |
| 66 | 66 | const lines = (await Bun.file(`${ROOT}bin/cli.ts`).text()).split('\n') |
| 67 | 67 | const commands = new Map<string, Set<string>>() |
| 68 | const aliases = new Map<string, string>() | |
| 68 | 69 | const marks: Array<{ line: number, name: string }> = [] |
| 69 | 70 | |
| 70 | 71 | 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*,/) | |
| 72 | 75 | if (!match) |
| 73 | 76 | return |
| 74 | 77 | |
| @@ -78,6 +81,21 @@ async function readCliSurface(): Promise<Map<string, Set<string>>> { | ||
| 78 | 81 | commands.set(name, new Set(GLOBAL_FLAGS)) |
| 79 | 82 | }) |
| 80 | 83 | |
| 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 | ||
| 81 | 99 | lines.forEach((line, i) => { |
| 82 | 100 | const match = line.match(/^\s*\.option\((["'])(.+?)\1\s*,/) |
| 83 | 101 | if (!match) |
| @@ -96,6 +114,12 @@ async function readCliSurface(): Promise<Map<string, Set<string>>> { | ||
| 96 | 114 | commands.get(owner)!.add(token) |
| 97 | 115 | }) |
| 98 | 116 | |
| 117 | for (const [alias, target] of aliases) { | |
| 118 | const flags = commands.get(target) | |
| 119 | if (flags) | |
| 120 | commands.set(alias, flags) | |
| 121 | } | |
| 122 | ||
| 99 | 123 | return commands |
| 100 | 124 | } |
| 101 | 125 | |
| @@ -0,0 +1,76 @@ | ||
| 1 | import { 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 | */ | |
| 10 | const 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 | */ | |
| 18 | async 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 | ||
| 33 | describe('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 | }) | |