Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions packages/cli/src/commands/scan/cmd-scan-create.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Comment thread
jdalton marked this conversation as resolved.
return undefined
}

export const cmdScanCreate = {
description,
hidden,
Expand Down Expand Up @@ -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,
Expand Down
148 changes: 148 additions & 0 deletions packages/cli/test/unit/commands/scan/cmd-scan-create.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -1366,5 +1366,153 @@ describe('cmd-scan-create', () => {
expect(mockHandleCreateNewScan).not.toHaveBeenCalled()
})
})

describe('--default-branch misuse detection', () => {
it('fails when --default-branch=<name> 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=<name> 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 <name> 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'),
)
})
})
})
})
Loading