Skip to content

Support bash tab completion #535

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
May 19, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions .config/rollup.dist.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@ async function copyInitGradle() {
await fs.copyFile(filepath, destPath)
}

async function copyBashCompletion() {
// Lazily access constants.srcPath.
const filepath = path.join(
constants.srcPath,
'commands/install/socket-completion.bash'
)
// Lazily access constants.distPath.
const destPath = path.join(constants.distPath, 'socket-completion.bash')
await fs.copyFile(filepath, destPath)
}

async function copyPackage(pkgName) {
// Lazily access constants.distPath and constants.rootPath.
const externalPath = path.join(constants.rootPath, EXTERNAL)
Expand Down Expand Up @@ -407,6 +418,7 @@ export default async () => {
async writeBundle() {
await Promise.all([
copyInitGradle(),
copyBashCompletion(),
updatePackageJson(),
...EXTERNAL_PACKAGES.filter(n => n !== BLESSED_CONTRIB).map(n =>
copyPackage(n)
Expand Down
6 changes: 5 additions & 1 deletion src/cli.mts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { cmdScanCreate } from './commands/dependencies/cmd-dependencies.mts'
import { cmdDiffScan } from './commands/diff-scan/cmd-diff-scan.mts'
import { cmdFix } from './commands/fix/cmd-fix.mts'
import { cmdInfo } from './commands/info/cmd-info.mts'
import { cmdInstall } from './commands/install/cmd-install.mts'
import { cmdLogin } from './commands/login/cmd-login.mts'
import { cmdLogout } from './commands/logout/cmd-logout.mts'
import { cmdManifest } from './commands/manifest/cmd-manifest.mts'
Expand All @@ -32,6 +33,7 @@ import { cmdReport } from './commands/report/cmd-report.mts'
import { cmdRepos } from './commands/repos/cmd-repos.mts'
import { cmdScan } from './commands/scan/cmd-scan.mts'
import { cmdThreatFeed } from './commands/threat-feed/cmd-threat-feed.mts'
import { cmdUninstall } from './commands/uninstall/cmd-uninstall.mts'
import { cmdWrapper } from './commands/wrapper/cmd-wrapper.mts'
import constants from './constants.mts'
import { AuthError, InputError, captureException } from './utils/errors.mts'
Expand Down Expand Up @@ -59,6 +61,7 @@ void (async () => {
config: cmdConfig,
fix: cmdFix,
info: cmdInfo,
install: cmdInstall,
login: cmdLogin,
logout: cmdLogout,
npm: cmdNpm,
Expand All @@ -78,7 +81,8 @@ void (async () => {
analytics: cmdAnalytics,
'diff-scan': cmdDiffScan,
'threat-feed': cmdThreatFeed,
manifest: cmdManifest
manifest: cmdManifest,
uninstall: cmdUninstall
},
{
aliases: {},
Expand Down
76 changes: 76 additions & 0 deletions src/commands/install/cmd-install-completion.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { logger } from '@socketsecurity/registry/lib/logger'

import { handleInstallCompletion } from './handle-install-completion.mts'
import constants from '../../constants.mts'
import { commonFlags } from '../../flags.mts'
import { meowOrExit } from '../../utils/meow-with-subcommands.mts'
import { getFlagListOutput } from '../../utils/output-formatting.mts'

import type { CliCommandConfig } from '../../utils/meow-with-subcommands.mts'

const { DRY_RUN_BAILING_NOW } = constants

const config: CliCommandConfig = {
commandName: 'completion',
description: 'Install bash completion for Socket CLI',
hidden: true, // beta
flags: {
...commonFlags
},
help: (command, config) => `
Usage
$ ${command} [name=socket]

Installs bash completion for the Socket CLI. This will:
1. Source the completion script in your current shell
2. Add the source command to your ~/.bashrc if it's not already there

This command will only setup tab completion, nothing else.

Afterwards you should be able to type \`socket \` and then press tab to
have bash auto-complete/suggest the sub/command or flags.

Currently only supports bash.

The optional name argument allows you to enable tab completion on a command
name other than "socket". Mostly for debugging but also useful if you use a
different alias for socket on your system.

Options
${getFlagListOutput(config.flags, 6)}

Examples

$ ${command}
$ ${command} sd
$ ${command} ./sd
`
}

export const cmdInstallCompletion = {
description: config.description,
hidden: config.hidden,
run
}

async function run(
argv: string[] | readonly string[],
importMeta: ImportMeta,
{ parentName }: { parentName: string }
): Promise<void> {
const cli = meowOrExit({
argv,
config,
importMeta,
parentName
})

const targetName = cli.input[0] || 'socket'

if (cli.flags['dryRun']) {
logger.log(DRY_RUN_BAILING_NOW)
return
}

await handleInstallCompletion(String(targetName))
}
89 changes: 89 additions & 0 deletions src/commands/install/cmd-install-completion.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import path from 'node:path'

import { describe, expect } from 'vitest'

import constants from '../../../src/constants.mts'
import { cmdit, invokeNpm } from '../../../test/utils.mts'

const { CLI } = constants

describe('socket install completion', async () => {
// Lazily access constants.rootBinPath.
const entryPath = path.join(constants.rootBinPath, `${CLI}.js`)

cmdit(
['install', 'completion', '--help', '--config', '{}'],
'should support --help',
async cmd => {
const { code, stderr, stdout } = await invokeNpm(entryPath, cmd)
expect(stdout).toMatchInlineSnapshot(
`
"Install bash completion for Socket CLI

Usage
$ socket install completion [name=socket]

Installs bash completion for the Socket CLI. This will:
1. Source the completion script in your current shell
2. Add the source command to your ~/.bashrc if it's not already there

This command will only setup tab completion, nothing else.

Afterwards you should be able to type \`socket \` and then press tab to
have bash auto-complete/suggest the sub/command or flags.

Currently only supports bash.

The optional name argument allows you to enable tab completion on a command
name other than "socket". Mostly for debugging but also useful if you use a
different alias for socket on your system.

Options
--help Print this help

Examples

$ socket install completion
$ socket install completion sd
$ socket install completion ./sd"
`
)
expect(`\n ${stderr}`).toMatchInlineSnapshot(`
"
_____ _ _ /---------------
| __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted>
|__ | * | _| '_| -_| _| | Node: <redacted>, API token set: <redacted>
|_____|___|___|_,_|___|_|.dev | Command: \`socket install completion\`, cwd: <redacted>"
`)

expect(code, 'explicit help should exit with code 0').toBe(0)
expect(stderr, 'banner includes base command').toContain(
'`socket install completion`'
)
}
)

cmdit(
[
'install',
'completion',
'--dry-run',
'--config',
'{"apiToken":"anything"}'
],
'should require args with just dry-run',
async cmd => {
const { code, stderr, stdout } = await invokeNpm(entryPath, cmd)
expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`)
expect(`\n ${stderr}`).toMatchInlineSnapshot(`
"
_____ _ _ /---------------
| __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted>
|__ | * | _| '_| -_| _| | Node: <redacted>, API token set: <redacted>
|_____|___|___|_,_|___|_|.dev | Command: \`socket install completion\`, cwd: <redacted>"
`)

expect(code, 'dry-run should exit with code 0 if input ok').toBe(0)
}
)
})
24 changes: 24 additions & 0 deletions src/commands/install/cmd-install.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { cmdInstallCompletion } from './cmd-install-completion.mts'
import { meowWithSubcommands } from '../../utils/meow-with-subcommands.mts'

import type { CliSubcommand } from '../../utils/meow-with-subcommands.mts'

const description = 'Setup the Socket CLI command in your environment'

export const cmdInstall: CliSubcommand = {
description,
hidden: true, // beta
async run(argv, importMeta, { parentName }) {
await meowWithSubcommands(
{
completion: cmdInstallCompletion
},
{
argv,
description,
importMeta,
name: `${parentName} install`
}
)
}
}
70 changes: 70 additions & 0 deletions src/commands/install/cmd-install.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import path from 'node:path'

import { describe, expect } from 'vitest'

import constants from '../../../src/constants.mts'
import { cmdit, invokeNpm } from '../../../test/utils.mts'

const { CLI } = constants

describe('socket install', async () => {
// Lazily access constants.rootBinPath.
const entryPath = path.join(constants.rootBinPath, `${CLI}.js`)

cmdit(
['install', '--help', '--config', '{}'],
'should support --help',
async cmd => {
const { code, stderr, stdout } = await invokeNpm(entryPath, cmd)
expect(stdout).toMatchInlineSnapshot(
`
"Setup the Socket CLI command in your environment

Usage
$ socket install <command>

Commands
(none)

Options
--help Print this help

Examples
$ socket install --help"
`
)
expect(`\n ${stderr}`).toMatchInlineSnapshot(`
"
_____ _ _ /---------------
| __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted>
|__ | * | _| '_| -_| _| | Node: <redacted>, API token set: <redacted>
|_____|___|___|_,_|___|_|.dev | Command: \`socket install\`, cwd: <redacted>"
`)

expect(code, 'explicit help should exit with code 0').toBe(0)
expect(stderr, 'banner includes base command').toContain(
'`socket install`'
)
}
)

cmdit(
['install', '--dry-run', '--config', '{"apiToken":"anything"}'],
'should require args with just dry-run',
async cmd => {
const { code, stderr, stdout } = await invokeNpm(entryPath, cmd)
expect(stdout).toMatchInlineSnapshot(
`"[DryRun]: No-op, call a sub-command; ok"`
)
expect(`\n ${stderr}`).toMatchInlineSnapshot(`
"
_____ _ _ /---------------
| __|___ ___| |_ ___| |_ | Socket.dev CLI ver <redacted>
|__ | * | _| '_| -_| _| | Node: <redacted>, API token set: <redacted>
|_____|___|___|_,_|___|_|.dev | Command: \`socket install\`, cwd: <redacted>"
`)

expect(code, 'dry-run should exit with code 0 if input ok').toBe(0)
}
)
})
7 changes: 7 additions & 0 deletions src/commands/install/handle-install-completion.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { outputInstallCompletion } from './output-install-completion.mts'
import { setupTabCompletion } from './setup-tab-completion.mts'

export async function handleInstallCompletion(targetName: string) {
const result = await setupTabCompletion(targetName)
await outputInstallCompletion(result)
}
54 changes: 54 additions & 0 deletions src/commands/install/output-install-completion.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { logger } from '@socketsecurity/registry/lib/logger'

import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts'

import type { CResult } from '../../types.mts'

export async function outputInstallCompletion(
result: CResult<{
actions: string[]
bashrcPath: string
completionCommand: string
bashrcUpdated: boolean
foundBashrc: boolean
sourcingCommand: string
targetName: string
targetPath: string
}>
) {
if (!result.ok) {
process.exitCode = result.code ?? 1

logger.fail(failMsgWithBadge(result.message, result.cause))
return
}

logger.log('')
logger.log(
`Installation of tab completion for "${result.data.targetName}" finished!`
)
logger.log('')

result.data.actions.forEach(action => {
logger.log(` - ${action}`)
})
logger.log('')
logger.log('Socket tab completion works automatically in new terminals.')
logger.log('')
logger.log(
'Due to a bash limitation, tab completion cannot be enabled in the'
)
logger.log('current shell (bash instance) through NodeJS. You must either:')
logger.log('')
logger.log('1. Reload your .bashrc script (best):')
logger.log('')
logger.log(` source ~/.bashrc`)
logger.log('')
logger.log('2. Run these commands to load the completion script:')
logger.log('')
logger.log(` source ${result.data.targetPath}`)
logger.log(` ${result.data.completionCommand}`)
logger.log('')
logger.log('3. Or restart bash somehow (restart terminal or run `bash`)')
logger.log('')
}
Loading
Loading