UNPKG

tabtab

Version:

tab completion helpers, for node cli programs. Inspired by npm completion.

421 lines (367 loc) 12.6 kB
const fs = require('fs'); const path = require('path'); const untildify = require('untildify'); const { promisify } = require('es6-promisify'); const mkdirp = promisify(require('mkdirp')); const { tabtabDebug, systemShell, exists } = require('./utils'); const debug = tabtabDebug('tabtab:installer'); const readFile = promisify(fs.readFile); const writeFile = promisify(fs.writeFile); const unlink = promisify(fs.unlink); const { BASH_LOCATION, FISH_LOCATION, ZSH_LOCATION, COMPLETION_DIR, TABTAB_SCRIPT_NAME } = require('./constants'); /** * Little helper to return the correct file extension based on the SHELL value. * * @returns The correct file extension for the given SHELL script location */ const shellExtension = () => systemShell(); /** * Helper to return the correct script template based on the SHELL provided * * @param {String} shell - Shell to base the check on, defaults to system shell. * @returns The template script content, defaults to Bash for shell we don't know yet */ const scriptFromShell = (shell = systemShell()) => { if (shell === 'fish') { return path.join(__dirname, 'scripts/fish.sh'); } if (shell === 'zsh') { return path.join(__dirname, 'scripts/zsh.sh'); } // For Bash and others return path.join(__dirname, 'scripts/bash.sh'); }; /** * Helper to return the expected location for SHELL config file, based on the * provided shell value. * * @param {String} shell - Shell value to test against * @returns {String} Either ~/.bashrc, ~/.zshrc or ~/.config/fish/config.fish, * untildified. Defaults to ~/.bashrc if provided SHELL is not valid. */ const locationFromShell = (shell = systemShell()) => { if (shell === 'bash') return untildify(BASH_LOCATION); if (shell === 'zsh') return untildify(ZSH_LOCATION); if (shell === 'fish') return untildify(FISH_LOCATION); return BASH_LOCATION; }; /** * Helper to return the source line to add depending on the SHELL provided or detected. * * If the provided SHELL is not known, it returns the source line for a Bash shell. * * @param {String} scriptname - The script to source * @param {String} shell - Shell to base the check on, defaults to system * shell. */ const sourceLineForShell = (scriptname, shell = systemShell()) => { if (shell === 'fish') { return `[ -f ${scriptname} ]; and . ${scriptname}; or true`; } if (shell === 'zsh') { return `[[ -f ${scriptname} ]] && . ${scriptname} || true`; } // For Bash and others return `[ -f ${scriptname} ] && . ${scriptname} || true`; }; /** * Helper to check if a filename is one of the SHELL config we expect * * @param {String} filename - Filename to check against * @returns {Boolean} Either true or false */ const isInShellConfig = filename => [ BASH_LOCATION, ZSH_LOCATION, FISH_LOCATION, untildify(BASH_LOCATION), untildify(ZSH_LOCATION), untildify(FISH_LOCATION) ].includes(filename); /** * Checks a given file for the existence of a specific line. Used to prevent * adding multiple completion source to SHELL scripts. * * @param {String} filename - The filename to check against * @param {String} line - The line to look for * @returns {Boolean} true or false, false if the line is not present. */ const checkFilenameForLine = async (filename, line) => { debug('Check filename (%s) for "%s"', filename, line); let filecontent = ''; try { filecontent = await readFile(untildify(filename), 'utf8'); } catch (err) { if (err.code !== 'ENOENT') { return console.error( 'Got an error while trying to read from %s file', filename, err ); } } return !!filecontent.match(`${line}`); }; /** * Opens a file for modification adding a new `source` line for the given * SHELL. Used for both SHELL script and tabtab internal one. * * @param {Object} options - Options with * - filename: The file to modify * - scriptname: The line to add sourcing this file * - name: The package being configured */ const writeLineToFilename = ({ filename, scriptname, name }) => ( resolve, reject ) => { const filepath = untildify(filename); debug('Creating directory for %s file', filepath); mkdirp(path.dirname(filepath)) .then(() => { const stream = fs.createWriteStream(filepath, { flags: 'a' }); stream.on('error', reject); stream.on('finish', () => resolve()); debug('Writing to shell configuration file (%s)', filename); debug('scriptname:', scriptname); const inShellConfig = isInShellConfig(filename); if (inShellConfig) { stream.write(`\n# tabtab source for packages`); } else { stream.write(`\n# tabtab source for ${name} package`); } stream.write('\n# uninstall by removing these lines'); stream.write(`\n${sourceLineForShell(scriptname)}`); stream.end('\n'); console.log('=> Added tabtab source line in "%s" file', filename); }) .catch(err => { console.error('mkdirp ERROR', err); reject(err); }); }; /** * Writes to SHELL config file adding a new line, but only one, to the SHELL * config script. This enables tabtab to work for the given SHELL. * * @param {Object} options - Options object with * - location: The SHELL script location (~/.bashrc, ~/.zshrc or * ~/.config/fish/config.fish) * - name: The package configured for completion */ const writeToShellConfig = async ({ location, name }) => { const scriptname = path.join( COMPLETION_DIR, `${TABTAB_SCRIPT_NAME}.${shellExtension()}` ); const filename = location; // Check if SHELL script already has a line for tabtab const existing = await checkFilenameForLine(filename, scriptname); if (existing) { return console.log('=> Tabtab line already exists in %s file', filename); } return new Promise( writeLineToFilename({ filename, scriptname, name }) ); }; /** * Writes to tabtab internal script that acts as a frontend router for the * completion mechanism, in the internal ~/.config/tabtab directory. Every * completion is added to this file. * * @param {Object} options - Options object with * - name: The package configured for completion */ const writeToTabtabScript = async ({ name }) => { const filename = path.join( COMPLETION_DIR, `${TABTAB_SCRIPT_NAME}.${shellExtension()}` ); const scriptname = path.join(COMPLETION_DIR, `${name}.${shellExtension()}`); // Check if tabtab completion file already has this line in it const existing = await checkFilenameForLine(filename, scriptname); if (existing) { return console.log('=> Tabtab line already exists in %s file', filename); } return new Promise(writeLineToFilename({ filename, scriptname, name })); }; /** * This writes a new completion script in the internal `~/.config/tabtab` * directory. Depending on the SHELL used, a different script is created for * the given SHELL. * * @param {Object} options - Options object with * - name: The package configured for completion * - completer: The binary that will act as the completer for `name` program */ const writeToCompletionScript = ({ name, completer }) => { const filename = untildify( path.join(COMPLETION_DIR, `${name}.${shellExtension()}`) ); const script = scriptFromShell(); debug('Writing completion script to', filename); debug('with', script); return readFile(script, 'utf8') .then(filecontent => filecontent .replace(/\{pkgname\}/g, name) .replace(/{completer}/g, completer) // on Bash on windows, we need to make sure to remove any \r .replace(/\r?\n/g, '\n') ) .then(filecontent => mkdirp(path.dirname(filename)).then(() => writeFile(filename, filecontent) ) ) .then(() => console.log('=> Wrote completion script to %s file', filename)) .catch(err => console.error('ERROR:', err)); }; /** * Top level install method. Does three things: * * - Writes to SHELL config file, adding a new line to tabtab internal script. * - Creates or edit tabtab internal script * - Creates the actual completion script for this package. * * @param {Object} options - Options object with * - name: The program name to complete * - completer: The actual program or binary that will act as the completer * for `name` program. Can be the same. * - location: The SHELL script config location (~/.bashrc, ~/.zshrc or * ~/.config/fish/config.fish) */ const install = async (options = { name: '', completer: '', location: '' }) => { debug('Install with options', options); if (!options.name) { throw new Error('options.name is required'); } if (!options.completer) { throw new Error('options.completer is required'); } if (!options.location) { throw new Error('options.location is required'); } await Promise.all([ writeToShellConfig(options), writeToTabtabScript(options), writeToCompletionScript(options) ]).then(() => { const { location, name } = options; console.log(` => Tabtab source line added to ${location} for ${name} package. Make sure to reload your SHELL. `); }); }; /** * Removes the 3 relevant lines from provided filename, based on the package * name passed in. * * @param {String} filename - The filename to operate on * @param {String} name - The package name to look for */ const removeLinesFromFilename = async (filename, name) => { /* eslint-disable no-unused-vars */ debug('Removing lines from %s file, looking for %s package', filename, name); if (!(await exists(filename))) { return debug('File %s does not exist', filename); } const filecontent = await readFile(filename, 'utf8'); const lines = filecontent.split(/\r?\n/); const sourceLine = isInShellConfig(filename) ? `# tabtab source for packages` : `# tabtab source for ${name} package`; const hasLine = !!filecontent.match(`${sourceLine}`); if (!hasLine) { return debug('File %s does not include the line: %s', filename, sourceLine); } let lineIndex = -1; const buffer = lines // Build up the new buffer, removing the 3 lines following the sourceline .map((line, index) => { const match = line.match(sourceLine); if (match) { lineIndex = index; } else if (lineIndex + 3 <= index) { lineIndex = -1; } return lineIndex === -1 ? line : ''; }) // Remove any double empty lines from this file .map((line, index, array) => { const next = array[index + 1]; if (line === '' && next === '') { return; } return line; }) // Remove any undefined value from there .filter(line => line !== undefined) .join('\n') .trim(); await writeFile(filename, buffer); console.log('=> Removed tabtab source lines from %s file', filename); }; /** * Here the idea is to uninstall a given package completion from internal * tabtab scripts and / or the SHELL config. * * It also removes the relevant scripts if no more completion are installed on * the system. * * @param {Object} options - Options object with * - name: The package name to look for */ const uninstall = async (options = { name: '' }) => { debug('Uninstall with options', options); const { name } = options; if (!name) { throw new Error('Unable to uninstall if options.name is missing'); } const completionScript = untildify( path.join(COMPLETION_DIR, `${name}.${shellExtension()}`) ); // First, lets remove the completion script itself if (await exists(completionScript)) { await unlink(completionScript); console.log('=> Removed completion script (%s)', completionScript); } // Then the lines in ~/.config/tabtab/__tabtab.shell const tabtabScript = untildify( path.join(COMPLETION_DIR, `${TABTAB_SCRIPT_NAME}.${shellExtension()}`) ); await removeLinesFromFilename(tabtabScript, name); // Then, check if __tabtab.shell is empty, if so remove the last source line in SHELL config const isEmpty = (await readFile(tabtabScript, 'utf8')).trim() === ''; if (isEmpty) { const shellScript = locationFromShell(); debug( 'File %s is empty. Removing source line from %s file', tabtabScript, shellScript ); await removeLinesFromFilename(shellScript, name); } console.log('=> Uninstalled completion for %s package', name); }; module.exports = { install, uninstall, checkFilenameForLine, writeToShellConfig, writeToTabtabScript, writeToCompletionScript, writeLineToFilename };