Skip to content

Building CLI Apps in Node

Half the tools you use in the terminal are Node.js under the hood ESLint , Prettier , TypeScript , npm , yarn , create-react-app - all CLI tools built with Node Building CLI apps is where Node shines: quick to write , easy to distribute , runs everywhere

Shebang and process.argv

#!/usr/bin/env node
// Every Node CLI starts with this shebang line

const args = process.argv.slice(2)
console.log('Arguments:', args)

// process.argv[0] = path to node binary
// process.argv[1] = path to script
// process.argv[2+] = user arguments

Make the file executable:

chmod +x mycli.js
./mycli.js --name Mahmoud --verbose

yargs - Argument Parsing

Don't parse process.argv yourself - use yargs or commander

#!/usr/bin/env node
const yargs = require('yargs')
const { hideBin } = require('yargs/helpers')

const argv = yargs(hideBin(process.argv))
  .usage('$0 <command> [options]')
  .command('scan <target>', 'Scan a target', (yargs) => {
    yargs.positional('target', {
      describe: 'Target host or IP',
      type: 'string'
    })
  }, (argv) => {
    console.log(`Scanning ${argv.target}...`)
  })
  .option('verbose', {
    alias: 'v',
    type: 'boolean',
    description: 'Verbose output'
  })
  .option('timeout', {
    alias: 't',
    type: 'number',
    default: 5000,
    description: 'Timeout in ms'
  })
  .option('config', {
    alias: 'c',
    type: 'string',
    description: 'Config file path'
  })
  .demandCommand(1, 'You need at least one command')
  .strict()
  .help()
  .argv

if (argv.verbose) {
  console.log('Verbose mode enabled')
}
./mycli.js scan 10.0.0.1 --verbose --timeout 10000

Reading stdin and Writing stdout/stderr

#!/usr/bin/env node
// Read stdin stream
async function readStdin() {
  const chunks = []
  for await (const chunk of process.stdin) {
    chunks.push(chunk)
  }
  return Buffer.concat(chunks).toString()
}

// Write to stdout
process.stdout.write('Output to stdout\n')

// Write to stderr (errors, diagnostics)
process.stderr.write('Error: something broke\n')

// Exit with code
// process.exit(0)   - success
// process.exit(1)   - general error
// process.exit(2)   - misuse of shell builtins
// Pipe data through your CLI
// cat largefile.txt | node mycli.js
const data = await readStdin()
const lines = data.split('\n').filter(l => l.includes('ERROR'))
lines.forEach(l => process.stdout.write(l + '\n'))

Interactive Prompts - inquirer

const inquirer = require('inquirer')

async function main() {
  const answers = await inquirer.prompt([
    {
      type: 'input',
      name: 'target',
      message: 'Enter target host:',
      validate: (input) => input.length > 0 || 'Target is required'
    },
    {
      type: 'list',
      name: 'scanType',
      message: 'Select scan type:',
      choices: ['Quick scan', 'Full scan', 'Custom'],
      default: 'Quick scan'
    },
    {
      type: 'checkbox',
      name: 'ports',
      message: 'Select ports:',
      choices: [
        { name: 'HTTP (80)', value: 80, checked: true },
        { name: 'HTTPS (443)', value: 443, checked: true },
        { name: 'SSH (22)', value: 22 },
        { name: 'FTP (21)', value: 21 }
      ]
    },
    {
      type: 'password',
      name: 'apiKey',
      message: 'Enter API key:',
      mask: '*'
    },
    {
      type: 'confirm',
      name: 'confirmed',
      message: 'Start scan?',
      default: false
    }
  ])

  if (answers.confirmed) {
    console.log(`Scanning ${answers.target} on ports ${answers.ports.join(', ')}`)
  }
}

main().catch(console.error)

CLI Visuals - chalk , ora , boxen

#!/usr/bin/env node
const chalk = require('chalk')
const ora = require('ora')
const boxen = require('boxen')

async function main() {
  // Colored output
  console.log(chalk.green('✓ Target reachable'))
  console.log(chalk.red('✗ Port 22 closed'))
  console.log(chalk.yellow('⚠ Rate limit approaching'))
  console.log(chalk.blue.bold('Info:') + ' Running in verbose mode')
  console.log(chalk.bgRed.white(' CRITICAL '))

  // Spinner for async operations
  const spinner = ora('Scanning target...').start()

  await new Promise(r => setTimeout(r, 2000))

  spinner.succeed('Scan complete')
  // spinner.fail('Scan failed')
  // spinner.info('Scan interrupted')

  // Box for important info
  console.log(boxen(
    chalk.bold('Scan Results\n\n') +
    `Host: ${chalk.green('10.0.0.1')}\n` +
    `Open ports: ${chalk.yellow('5')}\n` +
    `Duration: ${chalk.blue('12.4s')}`,
    {
      padding: 1,
      margin: 1,
      borderStyle: 'round',
      borderColor: 'green'
    }
  ))
}

main()

Publishing as an npm CLI Tool

// package.json
{
  "name": "my-security-scanner",
  "version": "1.0.0",
  "description": "CLI security scanner",
  "bin": {
    "scan": "./bin/scan.js",
    "scan-report": "./bin/report.js"
  },
  "preferGlobal": true,
  "dependencies": {
    "yargs": "^17.0.0",
    "chalk": "^5.0.0",
    "ora": "^8.0.0",
    "inquirer": "^9.0.0"
  }
}
# Install from npm
npm install -g my-security-scanner

# Or run directly
npx my-security-scanner scan 10.0.0.1

# Users now have:
scan --help
scan-report --output html

The bin field maps command names to script paths Node automatically links these when the package is installed globally Scripts should start with #!/usr/bin/env node and be executable

Security - Command Injection in CLI Tools

// DANGER - shell injection via CLI arguments
const { execSync } = require('child_process')

const target = process.argv[2]
// Attacker: "./mycli.js ; rm -rf / ;"
const result = execSync(`nmap ${target}`, { shell: true })

CLI tools that spawn subprocesses with unsanitized user input are RCE vectors waiting to happen

// SAFE - use spawn with argument arrays
const { spawnSync } = require('child_process')

const target = process.argv[2]

// Validate input
if (!/^[a-zA-Z0-9\.\-]+$/.test(target)) {
  console.error('Invalid target')
  process.exit(1)
}

const result = spawnSync('nmap', ['-sV', target], { stdio: 'inherit' })

Argument injection - even with argument arrays , some binaries interpret -- flags

// DANGER - nmap interprets --script as a flag
spawnSync('nmap', ['-sV', '--script=http-vuln-check', target])
If target is --script=http-vuln-check , nmap loads the script anyway Sanitize inputs even when using spawn

Other CLI security concerns: - Don't log sensitive data (API keys , tokens) to stdout - Don't write config files with secrets to world-readable locations - Validate file paths to prevent path traversal in output files - Be careful with -- argument terminator for positionals

Prerequisites


next -> ref_01_events_api.md