diff --git a/packages/cli/src/commands/scan/cmd-scan-create.mts b/packages/cli/src/commands/scan/cmd-scan-create.mts index c1de899a2..c620dfe09 100644 --- a/packages/cli/src/commands/scan/cmd-scan-create.mts +++ b/packages/cli/src/commands/scan/cmd-scan-create.mts @@ -202,6 +202,62 @@ const generalFlags: MeowFlags = { }, } +const DEFAULT_BRANCH_FLAGS = ['--default-branch', '--defaultBranch'] +const DEFAULT_BRANCH_PREFIXES = DEFAULT_BRANCH_FLAGS.map(f => `${f}=`) + +function isBareIdentifier(token: string): boolean { + // Accept only tokens that look like a plain branch name. Anything + // with a path separator, dot, or colon is almost certainly a target + // path, URL, or something else the user meant as a positional arg. + return /^[A-Za-z0-9_-]+$/.test(token) +} + +function findDefaultBranchValueMisuse( + argv: readonly string[], +): { form: string; value: string } | undefined { + // `--default-branch=main` — unambiguous: the `=` form attaches a + // value to what meow treats as a boolean flag, so the value is + // silently dropped. + for (const arg of argv) { + const prefix = DEFAULT_BRANCH_PREFIXES.find(p => arg.startsWith(p)) + if (!prefix) { + continue + } + const value = arg.slice(prefix.length) + const normalized = value.toLowerCase() + if (normalized === 'true' || normalized === 'false' || value === '') { + continue + } + return { form: `${prefix}${value}`, value } + } + // `--default-branch main` — ambiguous in general (the next token + // could be a positional target path), but if the next token is a + // bare identifier (no `/`, `.`, `:`) AND the user didn't also pass + // `--branch` / `-b`, it's almost certainly a mis-typed branch name. + const hasBranchFlag = argv.some( + arg => + arg === '--branch' || + arg === '-b' || + arg.startsWith('--branch=') || + arg.startsWith('-b='), + ) + if (hasBranchFlag) { + return undefined + } + for (let i = 0; i < argv.length - 1; i += 1) { + const arg = argv[i]! + if (!DEFAULT_BRANCH_FLAGS.includes(arg)) { + continue + } + const next = argv[i + 1]! + if (next.startsWith('-') || !isBareIdentifier(next)) { + continue + } + return { form: `${arg} ${next}`, value: next } + } + return undefined +} + export const cmdScanCreate = { description, hidden, @@ -272,6 +328,25 @@ async function run( `, } + // `--default-branch` is declared boolean, so meow/yargs-parser + // silently drops any value attached to it — the resulting scan is + // untagged and invisible in the Main/PR dashboard tabs. Catch that + // shape before meow parses so the user sees an actionable error + // instead of a mysteriously-mislabelled scan hours later. + const defaultBranchMisuse = findDefaultBranchValueMisuse(argv) + if (defaultBranchMisuse) { + const { form, value } = defaultBranchMisuse + logger.fail( + `"${form}" looks like you meant to name the branch "${value}", but --default-branch is a boolean flag (no value).\n\n` + + `To scan "${value}" as the default branch, use --branch for the name and --default-branch as a flag:\n` + + ` socket scan create --branch ${value} --default-branch\n\n` + + `To scan a non-default branch, drop --default-branch:\n` + + ` socket scan create --branch ${value}`, + ) + process.exitCode = 2 + return + } + const cli = meowOrExit({ argv, config, diff --git a/packages/cli/test/unit/commands/scan/cmd-scan-create.test.mts b/packages/cli/test/unit/commands/scan/cmd-scan-create.test.mts index be57cd47c..5a76205f5 100644 --- a/packages/cli/test/unit/commands/scan/cmd-scan-create.test.mts +++ b/packages/cli/test/unit/commands/scan/cmd-scan-create.test.mts @@ -1366,5 +1366,153 @@ describe('cmd-scan-create', () => { expect(mockHandleCreateNewScan).not.toHaveBeenCalled() }) }) + + describe('--default-branch misuse detection', () => { + it('fails when --default-branch= is passed with a branch name', async () => { + await cmdScanCreate.run( + ['--org', 'test-org', '--default-branch=main', '.'], + importMeta, + context, + ) + + expect(process.exitCode).toBe(2) + expect(mockHandleCreateNewScan).not.toHaveBeenCalled() + expect(mockLogger.fail).toHaveBeenCalledWith( + expect.stringContaining( + '"--default-branch=main" looks like you meant to name the branch "main"', + ), + ) + expect(mockLogger.fail).toHaveBeenCalledWith( + expect.stringContaining('--branch main --default-branch'), + ) + }) + + it('also catches the camelCase --defaultBranch= variant', async () => { + await cmdScanCreate.run( + ['--org', 'test-org', '--defaultBranch=main', '.'], + importMeta, + context, + ) + + expect(process.exitCode).toBe(2) + expect(mockHandleCreateNewScan).not.toHaveBeenCalled() + expect(mockLogger.fail).toHaveBeenCalledWith( + expect.stringContaining( + 'looks like you meant to name the branch "main"', + ), + ) + expect(mockLogger.fail).toHaveBeenCalledWith( + expect.stringContaining('"--defaultBranch=main"'), + ) + }) + + it('catches the legacy space-separated --default-branch form', async () => { + await cmdScanCreate.run( + ['--org', 'test-org', '--default-branch', 'main', '.'], + importMeta, + context, + ) + + expect(process.exitCode).toBe(2) + expect(mockHandleCreateNewScan).not.toHaveBeenCalled() + expect(mockLogger.fail).toHaveBeenCalledWith( + expect.stringContaining( + '"--default-branch main" looks like you meant to name the branch "main"', + ), + ) + }) + + it('leaves the space-separated form alone when --branch is also passed', async () => { + mockHasDefaultApiToken.mockReturnValueOnce(true) + + await cmdScanCreate.run( + [ + '--org', + 'test-org', + '--branch', + 'main', + '--default-branch', + '.', + '--no-interactive', + ], + importMeta, + context, + ) + + expect(mockLogger.fail).not.toHaveBeenCalledWith( + expect.stringContaining('looks like you meant'), + ) + }) + + it('does not misfire when the next token looks like a target path', async () => { + mockHasDefaultApiToken.mockReturnValueOnce(true) + + // `./some/dir` has path separators, so it is a positional target, + // not a mistyped branch name. + await cmdScanCreate.run( + [ + '--org', + 'test-org', + '--default-branch', + './some/dir', + '--no-interactive', + ], + importMeta, + context, + ) + + expect(mockLogger.fail).not.toHaveBeenCalledWith( + expect.stringContaining('looks like you meant'), + ) + }) + + it.each([ + '--default-branch=true', + '--default-branch=false', + '--default-branch=TRUE', + ])('allows %s (explicit boolean form)', async arg => { + mockHasDefaultApiToken.mockReturnValueOnce(true) + + await cmdScanCreate.run( + [ + '--org', + 'test-org', + '--branch', + 'main', + arg, + '.', + '--no-interactive', + ], + importMeta, + context, + ) + + expect(mockLogger.fail).not.toHaveBeenCalledWith( + expect.stringContaining('looks like you meant the branch name'), + ) + }) + + it('allows bare --default-branch (default truthy form)', async () => { + mockHasDefaultApiToken.mockReturnValueOnce(true) + + await cmdScanCreate.run( + [ + '--org', + 'test-org', + '--branch', + 'main', + '--default-branch', + '.', + '--no-interactive', + ], + importMeta, + context, + ) + + expect(mockLogger.fail).not.toHaveBeenCalledWith( + expect.stringContaining('looks like you meant the branch name'), + ) + }) + }) }) })