Skip to content
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

[rush] Idea: "rush symlink" built-in command. #4304

Open
elliot-nelson opened this issue Aug 31, 2023 · 2 comments
Open

[rush] Idea: "rush symlink" built-in command. #4304

elliot-nelson opened this issue Aug 31, 2023 · 2 comments

Comments

@elliot-nelson
Copy link
Collaborator

elliot-nelson commented Aug 31, 2023

Summary

An issue we've encountered in our repo is that we are consuming a package that lives outside the repo, and sometimes we need to test out a potential update to that package locally.

We've developed a custom script and mapped it to rush symlink, to make it easy for folks in our monorepo to perform this action. Perhaps this script could become the basis of a built-in feature of Rush, for mapping/unmapping a specific package/version locally to the specified target directory.

Details

Here is the current version of our script in its entirety, as a baseline:

#!/usr/bin/env node

/*
    This 'symlink' command is meant to create a "global" symlink of a 3rd party module
    directly in the Rush pnpm store.

    The benefit of such symlink is that it will affect all the projects of the mono-repo
    which depend on the same version of a 3rd party module.

    To reverse the process, run `rush install --purge` to revert all changes to the store.

    # Usage

    ```
    symlink [--help] [--path] PATH [--version VERSION] [--dry]

    - [--path] PATH: path to an external module location that you want to link
    - [--version] VERSION: a specific version that you want to link (otherwise all are linked)
    - [--dry]: dry-run flag, no filesystem changes
    ```

    # Details

    Example, initial layout:

    + acme/
      + common/temp/node_modules/.pnpm/
        + {library}@{version}/
          + node_modules/
            + {library}/
      + apps/
        + {project}/
          + node_modules/
            + {library}/ -> common/temp/node_modules/.pnpm/{library}@{version}/node_modules/{library}

    After linking to {local_library}:

    + acme/
      + common/temp/node_modules/.pnpm/
        + {library}@{version}/
          + node_modules/
            + {library}/ -> {local_library}
      + apps/
        + {project}/
          + node_modules/
            + {library}/ -> common/temp/node_modules/.pnpm/{library}@{version}/node_modules/{library} -> {local_library}

*/

const fs = require('fs');
const path = require('path');
const process = require("process");

process.exitCode = 1;

let args;
try {
    args = parseArgs(process.argv)
} catch (error) {
    console.error(error.message);
    console.error();
    console.error(getHelpMessage());
    return;
}

try {
    program(args);
    process.exitCode = 0;
} catch (error) {
    console.error(error.message);
    console.info('\nTo unlink or fix issues with your Rush pnpm store: `rush install --purge`');
}

/**
 * The main program for this script
 * @param {object} args The application arguments
 * @param {boolean} args.help True if the program should print a help message and exit
 * @param {boolean} args.dry Dry run will not make filesystem changes
 * @param {string} args.path Target external module to link
 * @param {string} args.version Module version to link (otherwise all are linked)
 */
 function program(args) {
    if (args.help || !args.path) {
        console.log(getHelpMessage());
        return;
    }

    if (!fs.existsSync(args.path)) {
        throw new Error(`${args.path} not found`);
    }

    // Get linked module package
    const pkgPath = path.join(args.path, 'package.json');
    const pkg = JSON.parse(fs.readFileSync(pkgPath).toString());
    const pkgMatch = pkg.name.replace('/', '+') + '@';

    // Find Rush pnpm store
    const storePath = findStoreModules();

    // Find matching installed modules
    const pnpmModules = fs.readdirSync(storePath).filter(m => m.startsWith(pkgMatch));

    // Do the linking
    let linked = false;
    for (const moduleName of pnpmModules) {
        if (!args.version || moduleName === `${pkgMatch}${args.version}`) {
            const modulePath = path.join(storePath, moduleName, 'node_modules', pkg.name);
            link(modulePath, args.path, args.dry);
            linked = true;
        }
    }
    if (!linked) {
        if (args.version) {
            throw new Error(`No matching module found with version ${args.version}`);
        } else {
            throw new Error('No matching module found');
        }
    }
}

/**
 * Find the isolated pnpm store in a Rush mono-repo
 * @returns {string} Path to pnpm modules
 */
function findStoreModules() {
    let parent = process.cwd();
    let current;
    do {
        current = parent;
        parent = path.dirname(current);
        if (fs.existsSync(path.join(current, 'rush.json'))) {
            return path.join(current, 'common', 'temp', 'node_modules', '.pnpm');
        }
    } while (parent !== current);
    throw new Error('Rush root not found');
}

/**
 * Create a symlink in the Rush pnpm store
 * @param {string} modulePath Module installed in the pnpm store
 * @param {string} targetPath Module to link
 * @param {boolean} dry Perform dry-run
 */
function link(modulePath, targetPath, dry) {
    // delete module in store
    rmOperation(modulePath, dry);
    // create symlink in store to target module
    lnOperation(modulePath, targetPath, dry);
}

/**
 * Symink a file to `sourcePath` at `targetPath`
 * @param {string} sourcePath
 * @param {string} targetPath
 * @param {boolean} dry Dry-run only
 */
function lnOperation(sourcePath, targetPath, dry) {
    console.log(`ln -s ${targetPath} ${sourcePath} `);
    if (!dry) {
        try {
            fs.symlinkSync(targetPath, sourcePath);
        } catch (error) {
            throw new Error(`Unable to create symlink: ${error.message}`);
        }
    }
}

/**
 * Remove a file or directory
 * @param {string} targetPath
 * @param {boolean} dry Dry-run only
 */
function rmOperation(targetPath, dry) {
    if (fs.lstatSync(targetPath).isDirectory()) {
        console.log(`rm -rf ${targetPath}`);
        if (targetPath.indexOf('/common/temp/') > 0) {
            if (!dry) {
                try {
                    fs.rmdirSync(targetPath, { recursive: true, force: true });
                } catch (error) {
                    throw new Error(`Unable to delete ${targetPath}`);
                }
            }
        } else {
            throw new Error('Script will not delete folders outside of Rush pnpm store');
        }
    } else {
        console.log(`rm ${targetPath}`);
        if (!dry) {
            try {
                fs.unlinkSync(targetPath);
            } catch (error) {
                throw new Error(`Unable to delete ${targetPath}`);
            }
        }
    }
}

/**
 * Gets the programs help message
 * @returns {string} The programs help message
 */
 function getHelpMessage() {
    return 'symlink [--help] [--path] PATH [--version VERSION] [--dry]\n\n' +
        '- [--path] PATH: path to an external module location that you want to link\n' +
        '- [--version] VERSION: a specific version that you want to link (otherwise all are linked)\n' +
        '- [--dry]: dry-run flag, no filesystem changes';
}

/**
 * Parses the command line array from process.argv and returns the programs options
 * @param {*} argv
 * @returns {Object} The programs arguments
 * {
 *   help: boolean,
 *   dry: boolean,
 *   link: string,
 *   version?: string,
 * }
 */
function parseArgs(argv) {
    // We are hand rolling our own command line parsing to cut down on the overhead of dependencies
    // And because the command line syntax of this program has been designed to be simple.
    const result = {};

    let switchName = undefined;
    let switchValueExpected = false;
    let pathProvided = false;
    for (let i = 2; i < argv.length; i++) {
        const arg = argv[i];

        if (switchValueExpected) {
            if (result[switchName] !== undefined) {
                throw new Error(`--${switchName} must be specified only once`);
            }
            result[switchName] = arg;

            switchName = undefined;
            switchValueExpected = false;
        } else {
            switch (arg) {
                case "--help":
                case "--dry": {
                    const flagName = arg.substr(2);
                    if (result[flagName] !== undefined) {
                        throw new Error(`--${flagName} must be specified only once`);
                    }

                    result[flagName] = true;
                    break;
                }

                case "--version":
                case "--path": {
                    switchName = arg.substr(2);
                    switchValueExpected = true;
                    break;
                }

                default: {
                    if (!pathProvided) {
                        pathProvided = true;
                        result['path'] = arg;
                    } else {
                        throw new Error(`${arg} is not a valid command line parameter`);
                    }
                }
            }
        }
    }
    return result;
}

And here is the command definition:

    {
      "name": "symlink",
      "commandKind": "global",
      "summary": "Create a global symlink to an external library.",
      "safeForSimultaneousRushProcesses": true,
      "shellCommand": "node common/scripts/symlink.js"
    }
...
    {
      "longName": "--path",
      "parameterKind": "string",
      "argumentName": "PATH",
      "description": "Path to an external module location to symlink",
      "required": true,
      "associatedCommands": ["symlink"]
    },
    {
      "longName": "--version",
      "parameterKind": "string",
      "argumentName": "VERSION",
      "description": "Link a specific version (otherwise all are linked)",
      "associatedCommands": ["symlink"]
    },
    {
      "longName": "--dry",
      "parameterKind": "flag",
      "description": "Dry-run only",
      "associatedCommands": ["symlink"]
    }

Standard questions

Please answer these questions to help us investigate your issue more quickly:

Question Answer
@microsoft/rush globally installed version? n/a
rushVersion from rush.json? n/a
useWorkspaces from rush.json? yes
Operating system? mac
Would you consider contributing a PR? yes
Node.js version (node -v)? 18
@dmichon-msft
Copy link
Contributor

You should just be able to use the rush-pnpm link command: https://pnpm.io/cli/link

@iclanton iclanton moved this to Needs triage in Bug Triage Sep 6, 2023
@iclanton iclanton moved this from Needs triage to General Discussions in Bug Triage Sep 18, 2023
@vjau
Copy link

vjau commented Sep 20, 2023

You should just be able to use the rush-pnpm link command: https://pnpm.io/cli/link

@dmichon-msft i couldn't get rush-pnpm link to work
I did pnpm link --global in the package (outside monorepo) folder.
I then did rush-pnpm link --global package-name inside the folder consuming the package in the monorepo.
I then did rush update per instructions.
But every time, the package installed is from npm and not from local package.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: General Discussions
Development

No branches or pull requests

3 participants