UNPKG

@pnpm/tabtab

Version:

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

469 lines (410 loc) 14.4 kB
const fs = require('fs'); const path = require('path'); const untildify = require('untildify'); const { promisify } = require('util'); const { tabtabDebug, exists } = require('./utils'); const { SUPPORTED_SHELLS } = require('./constants') const debug = tabtabDebug('tabtab:installer'); const readFile = promisify(fs.readFile); const writeFile = promisify(fs.writeFile); const unlink = promisify(fs.unlink); const mkdir = promisify(fs.mkdir); const { SHELL_LOCATIONS, COMPLETION_DIR, } = require('./constants'); const { templateFileName, completionFileName, tabtabFileName, } = require('./filename'); /** * @typedef {import('./constants').SupportedShell} SupportedShell */ /** * Helper to return the correct script template based on the SHELL provided * * @param {SupportedShell} shell - Shell to base the check on, defaults to system shell. * @returns {String} The template script content, defaults to Bash for shell we don't know yet */ const scriptFromShell = shell => path.join(__dirname, 'templates', templateFileName(shell)); /** * Helper to return the expected location for SHELL config file, based on the * provided shell value. * * @param {SupportedShell} 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 => { const location = SHELL_LOCATIONS[shell]; if (!location) { throw new Error(`Unsupported shell: ${shell}`); } return untildify(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 {SupportedShell} shell - Shell to base the check on */ const sourceLineForShell = (scriptname, shell) => { // Windows naturally uses `\` as path separator, which would be misinterpreted by the // shell interpreters. scriptname = scriptname.replaceAll('\\', '/'); if (shell === 'fish') { return `[ -f ${scriptname} ]; and . ${scriptname}; or true`; } if (shell === 'zsh') { return `[[ -f ${scriptname} ]] && . ${scriptname} || true`; } if (shell === 'pwsh') { return `if (Test-Path ${scriptname}) { . ${scriptname} }`; } if (shell === 'bash') { return `[ -f ${scriptname} ] && . ${scriptname} || true`; } throw new Error(`Unsupported shell: ${shell}`); }; /** * 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 => [ SHELL_LOCATIONS.bash, SHELL_LOCATIONS.zsh, SHELL_LOCATIONS.fish, SHELL_LOCATIONS.pwsh, untildify(SHELL_LOCATIONS.bash), untildify(SHELL_LOCATIONS.zsh), untildify(SHELL_LOCATIONS.fish), untildify(SHELL_LOCATIONS.pwsh), ].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 {Promise.<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 (/** @type {any} */ err) { if (err.code !== 'ENOENT') { console.error( 'Got an error while trying to read from %s file', filename, err ); return false; } } 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. * @param {String} options.filename - The file to modify. * @param {String} options.scriptname - The line to add sourcing this file. * @param {String} options.name - The package being configured. * @param {SupportedShell} options.shell * @returns {Promise.<void>} */ const writeLineToFilename = ({ filename, scriptname, name, shell }) => new Promise(( resolve, reject ) => { const filepath = untildify(filename); debug('Creating directory for %s file', filepath); mkdir(path.dirname(filepath), { recursive: true }) .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, shell)}`); 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 * @param {String} options.location - The SHELL script location (~/.bashrc, ~/.zshrc or * ~/.config/fish/config.fish) * @param {String} options.name - The package configured for completion * @param {SupportedShell} options.shell options.shell */ const writeToShellConfig = async ({ location, name, shell }) => { const scriptname = path.join( COMPLETION_DIR, shell, tabtabFileName(shell), ); 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 writeLineToFilename({ filename, scriptname, name, shell, }); }; /** * 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 * @param {String} options.name - The package configured for completion * @param {SupportedShell} options.shell */ const writeToTabtabScript = async ({ name, shell }) => { const filename = path.join( COMPLETION_DIR, shell, tabtabFileName(shell), ); const scriptname = path.join( COMPLETION_DIR, shell, completionFileName(name, shell), ); // 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 writeLineToFilename({ filename, scriptname, name, shell }); }; /** * Construct a completion script * @param {Object} options - Options object * @param {String} options.name - The package configured for completion * @param {String} options.completer - The program the will act as the completer for the `name` program * @param {SupportedShell} options.shell * @returns {Promise.<String>} */ const getCompletionScript = async ({ name, completer, shell }) => { const templatePath = scriptFromShell(shell); const templateContent = await readFile(templatePath, 'utf8'); const scriptContent = templateContent .replaceAll('{pkgname}', name) .replaceAll('{completer}', completer) // on Bash on windows, we need to make sure to remove any \r .replaceAll(/\r?\n/g, '\n'); return scriptContent }; /** * 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 * @param {String} options.name - The package configured for completion * @param {String} options.completer - The binary that will act as the completer for `name` program * @param {SupportedShell} options.shell */ const writeToCompletionScript = async ({ name, completer, shell }) => { const filename = untildify( path.join(COMPLETION_DIR, shell, completionFileName(name, shell)) ); try { const filecontent = await getCompletionScript({ name, completer, shell }) debug('Writing completion script to', filename); await mkdir(path.dirname(filename), { recursive: true }); await writeFile(filename, filecontent); 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 * @param {String} options.name - The program name to complete * @param {String} options.completer - The actual program or binary that will act as the completer * for `name` program. Can be the same. * @param {String} options.location - The SHELL script config location (~/.bashrc, ~/.zshrc or * ~/.config/fish/config.fish) * @param {SupportedShell} options.shell - the target shell language */ const install = async options => { debug('Install with options', options); if (!options) { throw new Error('options is required'); } 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) ]); 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 sourceLine1 = `# tabtab source for packages`; const sourceLine2 = `# tabtab source for ${name} package`; const hasLine1 = filecontent.includes(sourceLine1); if (!hasLine1) { debug('File %s does not include the line: %s', filename, sourceLine1); } const hasLine2 = filecontent.includes(sourceLine2); if (!hasLine2) { debug('File %s does not include the line: %s', filename, sourceLine2); } const hasLine = hasLine1 || hasLine2; if (!hasLine) { return debug('File %s does not include either line', filename); } 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(sourceLine1) ?? line.match(sourceLine2); 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); }; /** * Uninstall shell completion for one program from one or all supported shells. * * It also removes the relevant scripts if no more completion are installed on * the system. * * @param {Object} options * @param {String} options.name - Name of the target program. * @param {SupportedShell} [options.shell] - The target shell language. If not specified, target all supported shells. */ const uninstall = async options => { debug('Uninstall with options', options); if (!options) { throw new Error('options is required'); } const { name, shell } = options; if (!name) { throw new Error('Unable to uninstall if options.name is missing'); } if (!shell) { await Promise.all(SUPPORTED_SHELLS.map(shell => uninstall({ name, shell }))); return; } const completionScript = untildify( path.join(COMPLETION_DIR, shell, completionFileName(name, shell)) ); // 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, shell, tabtabFileName(shell), ) ); 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(shell); 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, getCompletionScript, writeToShellConfig, writeToTabtabScript, writeToCompletionScript, writeLineToFilename };