diff --git a/README.md b/README.md index 3332501..fc7cc1b 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ $ nvs which myalias/32 [An alias may also refer to a local directory](doc/ALIAS.md#aliasing-directories), enabling NVS to switch to a local private build of node. ## Automatic switching per directory -In either Bash or PowerShell, NVS can automatically switch the node version in the current shell as you change directories. This function is disabled by default; to enable it run `nvs auto on`. Afterward, whenever you `cd` or `pushd` under a directory containing a `.node-version` or an [`.nvmrc`](https://github.com/nvm-sh/nvm#nvmrc) file then NVS will automatically switch the node version accordingly, downloading a new version if necessary. When you `cd` out to a directory with no `.node-version` or `.nvmrc` file anywhere above it, then the default (linked) version is restored, if any. +In either Bash or PowerShell, NVS can automatically switch the node version in the current shell as you change directories. This function is disabled by default; to enable it run `nvs auto on`. Afterward, whenever you `cd` or `pushd` under a directory containing a `package.json`, `.node-version`, or an [`.nvmrc`](https://github.com/nvm-sh/nvm#nvmrc) file then NVS will automatically switch the node version accordingly, downloading a new version if necessary. When you `cd` out to a directory with no `package.json`, `.node-version`, or `.nvmrc` file anywhere above it, then the default (linked) version is restored, if any. If a `package.json` is found we'll prefer the [officially recommended](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#devengines) `devEngines.runtime.version` over all other options. ``` ~$ nvs link 6.9.1 ~/.nvs/default -> ~/.nvs/node/6.9.1/x64 diff --git a/doc/AUTO.md b/doc/AUTO.md index 1bd856b..9a3d255 100644 --- a/doc/AUTO.md +++ b/doc/AUTO.md @@ -1,10 +1,12 @@ # AUTO Command - Node Version Switcher - nvs auto - nvs auto on - nvs auto off +``` +nvs auto +nvs auto on +nvs auto off +``` -When invoked with no parameters, `nvs auto` searches for the nearest `.node-version` file in the current directory or parent directories. If found, the version specified in the file is then downloaded (if necessary) and used. If no `.node-version` file is found, then the default (linked) version, if any, is used. +When invoked with no parameters, `nvs auto` searches for the nearest `package.json` file, if found, it will use the Node version supplied by [devEngines](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#devengines) or the [volta](https://github.com/volta-cli/volta) object if either are available. If not, it will use the nearest `.node-version` or `.nvmrc` file in the current directory or parent directories. If a Node version is found in any of these locations, it will be downloaded (if necessary) and used. If no Node version is found, then the default (linked) version, if any, is used. The `nvs auto on` command enables automatic switching as needed whenever the current shell's working directory changes; `nvs auto off` disables automatic switching in the current shell. (This feature is not supported in Windows Command Prompt.) diff --git a/lib/auto.js b/lib/auto.js index 29ed56a..46b08f5 100644 --- a/lib/auto.js +++ b/lib/auto.js @@ -22,32 +22,242 @@ let nvsLink = null; // Lazy load function findAutoVersionAsync(cwd) { let version = null; let dir = cwd || process.cwd(); - while (dir) { - let versionFile = path.join(dir, '.node-version'); - let versionString; - try { - versionString = fs.readFileSync(versionFile, 'utf8').trim(); - } catch (e) { - Error.throwIfNot(Error.ENOENT, e, 'Failed to read file: ' + versionFile); + /** + * Reads and parses the package.json to find devEngines or Volta object. + * + * @param {string} directory Current working path + * @param {string} versionString The Node version if already found + * @return {string} Updated versionString + */ + function findDevEnginesOrVolta(directory, versionString) { + /** + * Reads in the package.json and parse it to the JSON manifest. + * + * @param {string} directory Current working path to look for package.json in + * @return {object} The parsed manifest + */ + function getManifest(directory) { + let manifest; + if (directory) { + let manifestFile = path.join(directory, 'package.json'); + try { + if (fs.existsSync(manifestFile)) { + manifest = fs.readFileSync(manifestFile, 'utf8'); + manifest = JSON.parse(manifest); + } + } catch (error) { + Error.throwIfNot(Error.ENOENT, error, 'Failed to read file: ' + manifestFile); + } + } + return manifest; } - // If we don't have a version string, try checking for an .nvmrc file - if (!versionString && !settings.disableNvmrc) { - versionFile = path.join(dir, '.nvmrc'); + if (directory && !versionString) { + const manifest = getManifest(directory); + if (manifest) { + if ( + manifest.devEngines && + manifest.devEngines.runtime + ) { + // Future proofing: Spec mentions runtime shorthand may be added later + if (typeof(manifest.devEngines.runtime) === 'string') { + versionString = manifest.devEngines.runtime; + } else if ( + manifest.devEngines.runtime.name && + manifest.devEngines.runtime.name === 'node' && + manifest.devEngines.runtime.version + ) { + versionString = manifest.devEngines.runtime.version; + } + } + if ( + !versionString && + manifest.volta && + manifest.volta.node + ) { + versionString = manifest.volta.node; + } + } + } + return versionString; + } + /** + * Get the Node version from files that only store it, like + * .nvmrc or .node-version. + * + * @param {string} directory Current working path + * @param {string} versionString The Node version if already found + * @param {string} fileName The file to open ('.nvmrc', '.node-version') + * @return {string} Updated versionString + */ + function findVersionFile(directory, versionString, fileName) { + if (directory && !versionString) { + let versionFile = path.join(directory, fileName); try { - versionString = fs.readFileSync(versionFile, 'utf8').trim(); - } catch (e) { - Error.throwIfNot(Error.ENOENT, e, 'Failed to read file: ' + versionFile); + if (fs.existsSync(versionFile)) { + versionString = fs.readFileSync(versionFile, 'utf8').trim(); + } + } catch (error) { + Error.throwIfNot(Error.ENOENT, error, 'Failed to read file: ' + versionFile); } } + return versionString; + } + /** + * Get the Node version from the .node-version file. + * + * @param {string} directory Current working path + * @param {string} versionString The Node version if already found + * @return {string} Updated versionString + */ + function findDotNodeVersion(directory, versionString) { + return findVersionFile(directory, versionString, '.node-version'); + } + /** + * Get the Node version from the .nvmrc file. + * + * @param {string} directory Current working path + * @param {string} versionString The Node version if already found + * @return {string} Updated versionString + */ + function findDotNvmrc(directory, versionString) { + if (!settings.disableNvmrc) { + return findVersionFile(directory, versionString, '.nvmrc'); + } + return versionString; + } + /** + * Gets the Node version from the Mise or Proto TOML file. + * + * @param {string} directory Current working path + * @param {string} versionString The Node version if already found + * @param {'mise'|'proto'} toolName Which Tool is the TOML file associated with + * @return {string} Updated versionString + */ + function getNodeVersionFromTomlFile(directory, versionString, toolName) { + if (!directory || versionString) { + return versionString; + } + /** + * Converts CRLF and CR line endings to LF. + * + * @param {string} contents Any text + * @return {string} The same text with normalized LF line-endings + */ + function normalizeLineEndings(contents) { + // convert all line endings to LF + return contents + // CRLF => LF + .split('\r\n').join('\n') + // CR => LF + .split('\r').join('\n'); + } + /** + * Manually extracts the Node version number defined in the TOML file. + * @param {string} contents The contents of the TOML file + * @return {string} The Node version number, '24.0.0' + */ + function findNodeVersionInToml(contents) { + // Break up TOML file contents to lines + const lines = contents.split('\n'); + // Find the first line with 'node = "24.0.0"' + let nodeLine = lines.filter((line) => { + return ( + line.includes('node=') || + line.includes('node =') + ); + })[0] || ''; + // ' "24.0.0"' + let nodeVersion = nodeLine.split('=')[1] || ''; + // ' "24.0.0"' => '24.0.0' + nodeVersion = nodeVersion + .split('"').join('') + .split('\'').join('') + .trim(); + // '24.0.0' || '' + return nodeVersion; + } + /** + * Tries to read in the file contents for a TOML file and normalize it. + * + * @param {string} directory Current working path + * @param {string} fileName The name of the file to read in the directory + * @return {string} The normalized file contents + */ + function readTheTomlContents(directory, fileName) { + let contents = ''; + let versionFile = path.join(directory, fileName); + try { + if (fs.existsSync(versionFile)) { + contents = fs.readFileSync(versionFile, 'utf8'); + contents = normalizeLineEndings(contents); + contents = contents.toLowerCase(); + contents = contents.trim(); + } + } catch (error) { + Error.throwIfNot(Error.ENOENT, error, 'Failed to read file: ' + versionFile); + } + return contents; + } + /** + * Reads in the .prototools file contents and extracts the Node version from it. + * + * @param {string} directory Current working path + * @param {string} versionString The Node version if already found + * @return {string} Updated versionString + */ + function findDotPrototools(directory, versionString) { + let contents = readTheTomlContents(directory, '.prototools'); + versionString = findNodeVersionInToml(contents) || versionString; + return versionString; + } + /** + * Reads in the mise.toml file contents and extracts the Node version from it. + * + * @param {string} directory Current working path + * @param {string} versionString The Node version if already found + * @return {string} Updated versionString + */ + function findMiseDotToml(directory, versionString) { + let contents = readTheTomlContents(directory, 'mise.toml'); + // Remove everything before the tools section + contents = contents.split('[tools]')[1] || ''; + // Remove everything after the tools section + contents = contents.split('[')[0] || ''; + versionString = findNodeVersionInToml(contents); + return versionString; + } + + const toolNameMap = { + proto: findDotPrototools, + mise: findMiseDotToml + }; + if (toolNameMap[toolName]) { + return toolNameMap[toolName](directory, versionString); + } + return versionString; + } + while (dir) { + /** + * Attempt to find a Node version in various locations, + * once found, skip all subsequent checks. DevEngines + * is the official standardized location and should be + * prefererred over all other options. + */ + let versionString; + versionString = findDevEnginesOrVolta(dir, versionString); + versionString = findDotNodeVersion(dir, versionString); + versionString = findDotNvmrc(dir, versionString); + versionString = getNodeVersionFromTomlFile(dir, versionString, 'proto'); + versionString = getNodeVersionFromTomlFile(dir, versionString, 'mise'); if (versionString) { try { version = NodeVersion.parse(versionString); version.arch = version.arch || version.defaultArch; break; - } catch (e) { - throw new Error('Failed to parse version in file: ' + versionFile, e); + } catch (error) { + throw new Error('Failed to parse version', error); } } diff --git a/test/mocks/fs.js b/test/mocks/fs.js index 8e8da6b..caa4711 100644 --- a/test/mocks/fs.js +++ b/test/mocks/fs.js @@ -108,6 +108,10 @@ const mockFs = { try { cb(null, this.accessSync(path, mode)); } catch (e) { cb(e); } }, + existsSync(path) { + return true; + }, + statSync(path) { path = this.fixSep(path); if (this.trace) console.log('statSync(' + path + ')');