trufflesuite/truffle

View on GitHub
packages/core/lib/console.js

Summary

Maintainability
D
3 days
Test Coverage
const repl = require("repl");
const provision = require("@truffle/provisioner");
const {
  Web3Shim,
  createInterfaceAdapter
} = require("@truffle/interface-adapter");
const contract = require("@truffle/contract");
const os = require("os");
const vm = require("vm");
const expect = require("@truffle/expect");
const TruffleError = require("@truffle/error");
const fse = require("fs-extra");
const path = require("path");
const EventEmitter = require("events");
const { spawn } = require("child_process");
const Require = require("@truffle/require");
const debug = require("debug")("console");
const { parseQuotesAndEscapes } = require("./command-utils");
const {
  excludedTruffleConsoleCommands,
  validTruffleConsoleCommands
} = require("./commands/commands");

// Create an expression that returns a string when evaluated
// by the REPL
const makeIIFE = str => `(() => "${str}")()`;

const processInput = input => {
  const words = input.trim().split(/\s+/);

  // empty input
  if (words.length === 0) return input;

  // maybe truffle command
  if (words[0].toLowerCase() === "truffle") {
    const cmd = words[1];

    if (cmd === undefined) {
      return makeIIFE(
        `ℹ️ : 'Missing truffle command. Please include a valid truffle command.`
      );
    }

    const normalizedCommand = cmd.toLowerCase();
    const isExcludedInREPL =
      excludedTruffleConsoleCommands.includes(normalizedCommand);

    if (isExcludedInREPL) {
      return makeIIFE(
        `ℹ️ : '${words[0]} ${cmd}' is not allowed in Console environment.`
      );
    }

    if (!validTruffleConsoleCommands.includes(normalizedCommand)) {
      return makeIIFE(
        `ℹ️ : '${words[0]} ${cmd}' is not a valid truffle command.`
      );
    }

    return words.slice(1).join(" ");
  }

  // an expression
  return input.trim();
};

class Console extends EventEmitter {
  constructor(options) {
    super();
    EventEmitter.call(this);

    expect.options(options, [
      "working_directory",
      "contracts_directory",
      "contracts_build_directory",
      "migrations_directory",
      "networks",
      "network",
      "network_id",
      "provider",
      "resolver",
      "build_directory"
    ]);

    this.options = options;

    this.repl = null;
    // we need to keep track of name conflicts that occur between contracts and
    // repl context objects so as not to overwrite them - this is to prevent
    // overwriting Node native objects like Buffer, number, etc.
    this.replGlobals = new Set();
    this.knownReplNameConflicts = new Set();
    this.newReplNameConflicts = new Set();

    this.interfaceAdapter = createInterfaceAdapter({
      provider: options.provider,
      networkType: options.networks[options.network].type
    });
    this.web3 = new Web3Shim({
      provider: options.provider,
      networkType: options.networks[options.network].type
    });
  }

  recordNameConflicts(abstractions) {
    for (const abstraction of abstractions) {
      const name = abstraction.contract_name;
      if (
        !this.knownReplNameConflicts.has(name) &&
        this.replGlobals.has(name)
      ) {
        this.newReplNameConflicts.add(name);
        this.knownReplNameConflicts.add(name);
      }
    }
  }

  async start() {
    try {
      // start the repl with an empty prompt and show a proper one when
      // the repl has set up its context and is ready to accept input
      this.repl = repl.start({
        prompt: "",
        eval: this.interpret.bind(this)
      });

      // Get and set Truffle and User Globals
      const truffleAndUserGlobals = await this.calculateTruffleAndUserGlobals();
      Object.entries(truffleAndUserGlobals).forEach(([key, value]) => {
        this.repl.context[key] = value;
      });

      // record name conflicts to avoid clobbering globals with contracts
      this.replGlobals = new Set(
        Object.getOwnPropertyNames(this.repl.context.global)
      );

      // repl is ready - set and display prompt
      this.repl.setPrompt("truffle(" + this.options.network + ")> ");

      // hydrate the environment with the user's contracts
      this.provision();

      // provision first before displaying prompt so that if
      // there is a warning the user will end up at the prompt
      this.repl.displayPrompt();

      this.repl.on("exit", () => {
        process.exit();
      });

      // ensure that `await`-ing this method never resolves. (we want to keep
      // the console open until it exits on its own)
      return new Promise(() => {});
    } catch (error) {
      this.options.logger.log(
        "Unexpected error setting up the environment or provisioning " +
          "contracts while instantiating the console."
      );
      this.options.logger.log(error.stack || error.message || error);
    }
  }

  getUserDefinedGlobals({ accounts, interfaceAdapter, web3 }) {
    // exit if feature should be disabled
    if (this.options["require-none"]) return;

    // exit if no hydrate options are set
    if (
      (!this.options.console || !this.options.console.require) &&
      !this.options.require &&
      !this.options.r
    )
      return;

    const addToContext = (context, userData, namespace) => {
      for (const key in userData) {
        if (namespace) {
          if (typeof context[namespace] === "undefined") {
            context[namespace] = {};
          }
          context[namespace][key] = userData[key];
        } else {
          context[key] = userData[key];
        }
      }
    };
    const errorMessage =
      "You must specify the console.require property as " +
      "either a string or an array. If you specify an array, its members " +
      "must be paths or objects containing at least a `path` property.";

    const requireValue =
      this.options.r || this.options.require || this.options.console.require;

    // Require allows us to inject Truffle variables into the script's scope
    const requireOptions = {
      context: {
        accounts,
        interfaceAdapter,
        web3
      }
    };
    const userGlobals = {};
    if (typeof requireValue === "string") {
      requireOptions.file = requireValue;
      addToContext(userGlobals, Require.file(requireOptions));
    } else if (Array.isArray(requireValue)) {
      this.options.console.require.forEach(item => {
        if (typeof item === "string") {
          requireOptions.file = item;
          addToContext(userGlobals, Require.file(requireOptions));
        } else if (typeof item === "object" && item.path) {
          requireOptions.file = item.path;
          addToContext(userGlobals, Require.file(requireOptions), item.as);
        } else {
          throw new Error(errorMessage);
        }
      });
    } else {
      throw new Error(errorMessage);
    }
    return userGlobals;
  }

  async calculateTruffleAndUserGlobals() {
    let accounts;
    try {
      accounts = await this.interfaceAdapter.getAccounts();
    } catch {
      // don't prevent Truffle from working if user doesn't provide some way
      // to sign transactions (e.g. no reason to disallow debugging)
      accounts = [];
    }

    const userGlobals = this.getUserDefinedGlobals({
      web3: this.web3,
      interfaceAdapter: this.interfaceAdapter,
      accounts
    });

    const truffleGlobals = {
      web3: this.web3,
      interfaceAdapter: this.interfaceAdapter,
      accounts,
      artifacts: this.options.resolver
    };

    // we insert user variables first so as to not clobber Truffle's
    return {
      ...userGlobals,
      ...truffleGlobals
    };
  }

  provision() {
    let files = [];
    let jsonBlobs = [];
    try {
      files = fse.readdirSync(this.options.contracts_build_directory);
    } catch (error) {
      // Error reading the build directory? Must mean it doesn't exist or we don't have access to it.
      // Couldn't provision the contracts if we wanted. It's possible we're hiding very rare FS
      // errors, but that's better than showing the user error messages that will be "build folder
      // doesn't exist" 99.9% of the time.
    }

    files.forEach(file => {
      // filter out non artifacts
      if (!file.endsWith(".json")) return;

      try {
        const body = fse.readFileSync(
          path.join(this.options.contracts_build_directory, file),
          "utf8"
        );
        const json = JSON.parse(body);
        // Artifacts may not contain metadata. For example, early Solidity versions as well as
        // Vyper contracts do not include metadata. Just push them to json blobs.
        if (json.metadata === undefined) {
          jsonBlobs.push(json);
        } else {
          // filter out Truffle's console.log. We don't want users to interact with in the REPL.
          // user contracts named console.log will be imported, and a warning will be issued.
          const metadata = JSON.parse(json.metadata);
          const sources = Object.keys(metadata.sources);
          if (
            sources.length > 1 ||
            (sources.length === 1 &&
              !sources.some(source => {
                return (
                  source === "truffle/console.sol" ||
                  source === "truffle/Console.sol"
                );
              }))
          ) {
            jsonBlobs.push(json);
          }
        }
      } catch (error) {
        throw new Error(`Error parsing or reading ${file}: ${error.message}`);
      }
    });

    const abstractions = jsonBlobs.map(json => {
      const abstraction = contract(json);
      provision(abstraction, this.options);
      return abstraction;
    });

    this.recordNameConflicts(abstractions);

    this.resetContractsInConsoleContext(abstractions);
    return abstractions;
  }

  resetContractsInConsoleContext(abstractions) {
    abstractions = abstractions || [];

    const contextVars = {};

    abstractions.forEach(abstraction => {
      const name = abstraction.contract_name;
      // don't overwrite Node's native objects - only load contracts
      // into the repl context when no conflict exists
      if (!this.knownReplNameConflicts.has(name)) {
        contextVars[name] = abstraction;
      }
    });

    if (this.newReplNameConflicts.size > 0) {
      const contractNames = [...this.newReplNameConflicts.keys()];
      this.newReplNameConflicts.clear();
      console.log(
        `\n > Warning: One or more of your contract(s) has a name conflict ` +
          `with something in the current repl context and was not loaded by ` +
          `default. \n > You can use 'artifacts.require("<artifactName>")' ` +
          `to obtain a reference to your contract(s). \n > Truffle recommends ` +
          `that you rename your contract to avoid problems. \n > The following ` +
          `name conflicts exist: ${contractNames.join(", ")}.\n`
      );
    }

    // make sure the repl gets the new contracts in its context
    Object.keys(contextVars || {}).forEach(key => {
      this.repl.context[key] = contextVars[key];
    });
  }

  async runSpawn(inputStrings, options) {
    let childPath;
    /* eslint-disable no-undef */
    if (typeof BUNDLE_CONSOLE_CHILD_FILENAME !== "undefined") {
      childPath = path.join(__dirname, BUNDLE_CONSOLE_CHILD_FILENAME);
    } else {
      childPath = path.join(__dirname, "../lib/console-child.js");
    }

    const spawnOptions = { stdio: "pipe" };
    const settings = ["config", "network", "url"]
      .filter(setting => options[setting])
      .map(setting => `--${setting} ${options[setting]}`)
      .join(" ");

    const spawnInput = `${settings} -- ${inputStrings}`;

    const spawnedProcess = spawn(
      "node",
      ["--no-deprecation", childPath, spawnInput],
      spawnOptions
    );

    // Theoretically stderr can contain multiple errors.
    // So let's just print it instead of throwing through
    // the error handling mechanism. Bad call? Who knows...
    // better be safe and buffer stderr so that it doesn't
    // interrupt stdout, and present it as a complete
    // string at the end of the spawned process.
    let bufferedError = "";
    spawnedProcess.stderr.on("data", data => {
      bufferedError += data.toString();
    });

    spawnedProcess.stdout.on("data", data => {
      // convert buffer to string
      data = data.toString();
      // workaround: remove extra newline in `truffle develop` console
      // truffle test, for some reason, appends a newline to the data
      // it emits here.
      if (data.endsWith(os.EOL)) data = data.slice(0, -os.EOL.length);
      console.log(data);
    });

    return new Promise((resolve, reject) => {
      spawnedProcess.on("close", code => {
        // dump bufferedError
        debug(bufferedError);

        if (!code) {
          // re-provision to ensure any changes are available in the repl
          this.provision();

          //display prompt when child repl process is finished
          this.repl.displayPrompt();
          return void resolve();
        }
        reject(code);
      });
    });
  }

  async interpret(input, context, filename, callback) {
    const processedInput = processInput(input);
    if (validTruffleConsoleCommands.includes(processedInput.split(/\s+/)[0])) {
      try {
        parseQuotesAndEscapes(processedInput); //we're just doing this to see
        //if it errors. unfortunately we need to throw out the result and recompute
        //it afterward (but the input string is probably short so it's OK).
        await this.runSpawn(processedInput, this.options);
      } catch (error) {
        // Perform error handling ourselves.
        if (error instanceof TruffleError) {
          console.log(error.message);
        } else {
          // Bubble up all other unexpected errors.
          console.log(error.stack || error.toString());
        }
        return callback();
      }

      // Reprovision after each command as it may change contracts.
      try {
        this.provision();
        return callback();
      } catch (error) {
        // Don't pass abstractions to the callback if they're there or else
        // they'll get printed in the repl.
        return callback(error);
      }
    }

    // Much of the following code is from here, though spruced up:
    // https://github.com/nfcampos/await-outside

    /*
    - allow whitespace before everything else
    - optionally capture `var| let |const <varname> = `
        - varname only matches if it starts with a-Z or _ or $
        and if contains only those chars or numbers
        - this is overly restrictive but is easier to maintain
        - capture `await <anything that follows it>`
          */
    let includesAwait =
      /^\s*((?:(?:var|const|let)\s+)?[a-zA-Z_$][0-9a-zA-Z_$]*\s*=\s*)?(\(?\s*await[\s\S]*)/;

    const match = processedInput.match(includesAwait);
    let source = processedInput;
    let assignment = null;

    // If our code includes an await, add special processing to ensure it's evaluated properly.
    if (match) {
      let assign = match[1];

      const expression =
        match[2] && match[2].endsWith(";")
          ? // strip off trailing ";" to prevent the expression below from erroring
            match[2].slice(0, -1)
          : match[2];

      const RESULT = "__await_outside_result";

      // Wrap the await inside an async function.
      // Strange indentation keeps column offset correct in stack traces
      source = `(async function() { try {${
        assign ? `global.${RESULT} =` : "return"
      } (
          ${expression.trim()}
  ); } catch(e) {global.ERROR = e; throw e; } }())`;

      assignment = assign
        ? `${assign.trim()} global.${RESULT}; void delete global.${RESULT};`
        : null;

      //finally, if the assignment did not use var, const, or let, make sure to
      //return the result afterward
      if (assign) {
        const bareAssignmentMatch = assign.match(
          /^\s*([a-zA-Z_$][0-9a-zA-Z_$]*)\s*=\s*/
        );
        if (bareAssignmentMatch) {
          const varName = bareAssignmentMatch[1];
          assignment += varName;
        }
      }
    }

    const runScript = script => {
      const options = {
        displayErrors: false,
        breakOnSigint: true,
        filename: filename
      };

      vm.createContext(context);
      return script.runInContext(context, options);
    };

    let script;
    try {
      const options = { lineOffset: -1 };
      script = vm.createScript(source, options);
    } catch (error) {
      // If syntax error, or similar, bail.
      return callback(error);
    }

    // Ensure our script returns a promise whether we're using an
    // async function or not. If our script is an async function,
    // this will ensure the console waits until that await is finished.
    Promise.resolve(runScript(script))
      .then(value => {
        // If there's an assignment to run, run that.
        if (assignment) return runScript(vm.createScript(assignment));
        return value;
      })
      .then(value => {
        // All good? Return the value (e.g., eval'd script or assignment)
        callback(null, value);
      })
      .catch(callback);
  }
}

module.exports = {
  Console
};