UNPKG

@graphprotocol/graph-cli

Version:

CLI for building for and deploying to The Graph

804 lines (795 loc) 32.1 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const fs_1 = __importDefault(require("fs")); const os_1 = __importDefault(require("os")); const path_1 = __importDefault(require("path")); const core_1 = require("@oclif/core"); const gluegun_1 = require("gluegun"); const abi_1 = require("../command-helpers/abi"); const DataSourcesExtractor = __importStar(require("../command-helpers/data-sources")); const network_1 = require("../command-helpers/network"); const node_1 = require("../command-helpers/node"); const scaffold_1 = require("../command-helpers/scaffold"); const spinner_1 = require("../command-helpers/spinner"); const studio_1 = require("../command-helpers/studio"); const subgraph_1 = require("../command-helpers/subgraph"); const debug_1 = __importDefault(require("../debug")); const protocols_1 = __importDefault(require("../protocols")); const schema_1 = require("../scaffold/schema"); const validation_1 = require("../validation"); const add_1 = __importDefault(require("./add")); const initDebug = (0, debug_1.default)('graph-cli:init'); const protocolChoices = Array.from(protocols_1.default.availableProtocols().keys()); const availableNetworks = protocols_1.default.availableNetworks(); const DEFAULT_EXAMPLE_SUBGRAPH = 'ethereum/gravatar'; class InitCommand extends core_1.Command { async run() { const { args: { subgraphName, directory }, flags: { protocol, product, studio, node: nodeFlag, 'allow-simple-name': allowSimpleNameFlag, 'from-contract': fromContract, 'contract-name': contractName, 'from-example': fromExample, 'index-events': indexEvents, network, abi: abiPath, 'start-block': startBlock, }, } = await this.parse(InitCommand); let { node, allowSimpleName } = (0, node_1.chooseNodeUrl)({ product, studio, node: nodeFlag, allowSimpleName: allowSimpleNameFlag, }); if (fromContract && fromExample) { this.error('Only one of "--from-example" and "--from-contract" can be used at a time.', { exit: 1, }); } // Detect git const git = gluegun_1.system.which('git'); if (!git) { this.error('Git was not found on your system. Please install "git" so it is in $PATH.', { exit: 1, }); } // Detect Yarn and/or NPM const yarn = gluegun_1.system.which('yarn'); const npm = gluegun_1.system.which('npm'); if (!yarn && !npm) { this.error(`Neither Yarn nor NPM were found on your system. Please install one of them.`, { exit: 1, }); } const commands = { link: yarn ? 'yarn link @graphprotocol/graph-cli' : 'npm link @graphprotocol/graph-cli', install: yarn ? 'yarn' : 'npm install', codegen: yarn ? 'yarn codegen' : 'npm run codegen', deploy: yarn ? 'yarn deploy' : 'npm run deploy', }; // If all parameters are provided from the command-line, // go straight to creating the subgraph from the example if (fromExample && subgraphName && directory) { return await initSubgraphFromExample.bind(this)({ fromExample, allowSimpleName, directory, subgraphName, studio, product }, { commands }); } // Will be assigned below if ethereum let abi; // If all parameters are provided from the command-line, // go straight to creating the subgraph from an existing contract if (fromContract && protocol && subgraphName && directory && network && node) { if (!protocolChoices.includes(protocol)) { this.error(`Protocol '${protocol}' is not supported, choose from these options: ${protocolChoices.join(', ')}`, { exit: 1 }); } const protocolInstance = new protocols_1.default(protocol); if (protocolInstance.hasABIs()) { const ABI = protocolInstance.getABI(); if (abiPath) { try { abi = loadAbiFromFile(ABI, abiPath); } catch (e) { this.error(`Failed to load ABI: ${e.message}`, { exit: 1 }); } } else { try { if (network === 'poa-core') { abi = await (0, abi_1.loadAbiFromBlockScout)(ABI, network, fromContract); } else { abi = await (0, abi_1.loadAbiFromEtherscan)(ABI, network, fromContract); } } catch (e) { process.exitCode = 1; return; } } } return await initSubgraphFromContract.bind(this)({ protocolInstance, abi, allowSimpleName, directory, contract: fromContract, indexEvents, network, subgraphName, contractName, node, studio, product, startBlock, }, { commands, addContract: false }); } // Otherwise, take the user through the interactive form const answers = await processInitForm.bind(this)({ protocol: protocol, product, studio, node, abi, allowSimpleName, directory, contract: fromContract, indexEvents, fromExample, network, subgraphName, contractName, startBlock, }); if (!answers) { this.exit(1); return; } if (fromExample) { await initSubgraphFromExample.bind(this)({ fromExample, subgraphName: answers.subgraphName, directory: answers.directory, studio: answers.studio, product: answers.product, }, { commands }); } else { ({ node, allowSimpleName } = (0, node_1.chooseNodeUrl)({ product: answers.product, studio: answers.studio, node, allowSimpleName, })); await initSubgraphFromContract.bind(this)({ protocolInstance: answers.protocolInstance, allowSimpleName, subgraphName: answers.subgraphName, directory: answers.directory, abi: answers.abi, network: answers.network, contract: answers.contract, indexEvents: answers.indexEvents, contractName: answers.contractName, node, studio: answers.studio, product: answers.product, startBlock: answers.startBlock, }, { commands, addContract: true }); } } } InitCommand.description = 'Creates a new subgraph with basic scaffolding.'; InitCommand.args = { subgraphName: core_1.Args.string(), directory: core_1.Args.string(), }; InitCommand.flags = { help: core_1.Flags.help({ char: 'h', }), protocol: core_1.Flags.string({ options: protocolChoices, }), product: core_1.Flags.string({ summary: 'Selects the product for which to initialize.', options: ['subgraph-studio', 'hosted-service'], }), studio: core_1.Flags.boolean({ summary: 'Shortcut for "--product subgraph-studio".', exclusive: ['product'], }), node: core_1.Flags.string({ summary: 'Graph node for which to initialize.', char: 'g', }), 'allow-simple-name': core_1.Flags.boolean({ description: 'Use a subgraph name without a prefix.', default: false, }), 'from-contract': core_1.Flags.string({ description: 'Creates a scaffold based on an existing contract.', exclusive: ['from-example'], }), 'from-example': core_1.Flags.string({ description: 'Creates a scaffold based on an example subgraph.', // TODO: using a default sets the value and therefore requires not to have --from-contract // default: 'Contract', exclusive: ['from-contract'], }), 'contract-name': core_1.Flags.string({ helpGroup: 'Scaffold from contract', description: 'Name of the contract.', dependsOn: ['from-contract'], }), 'index-events': core_1.Flags.boolean({ helpGroup: 'Scaffold from contract', description: 'Index contract events as entities.', dependsOn: ['from-contract'], }), 'start-block': core_1.Flags.string({ helpGroup: 'Scaffold from contract', description: 'Block number to start indexing from.', // TODO: using a default sets the value and therefore requires --from-contract // default: '0', dependsOn: ['from-contract'], }), abi: core_1.Flags.string({ summary: 'Path to the contract ABI', // TODO: using a default sets the value and therefore requires --from-contract // default: '*Download from Etherscan*', dependsOn: ['from-contract'], }), network: core_1.Flags.string({ summary: 'Network the contract is deployed to.', dependsOn: ['from-contract'], options: [ ...availableNetworks.get('ethereum'), ...availableNetworks.get('near'), ...availableNetworks.get('cosmos'), ], }), }; exports.default = InitCommand; async function processInitForm({ protocol, product, studio, node, abi, allowSimpleName, directory, contract, indexEvents, fromExample, network, subgraphName, contractName, startBlock, }) { let abiFromEtherscan = undefined; let abiFromFile = undefined; let protocolInstance; let ProtocolContract; let ABI; const questions = [ { type: 'select', name: 'protocol', message: 'Protocol', choices: protocolChoices, skip: protocolChoices.includes(String(protocol)), result: (value) => { // eslint-disable-next-line -- prettier has problems with ||= protocol = protocol || value; protocolInstance = new protocols_1.default(protocol); return protocol; }, }, { type: 'select', name: 'product', message: 'Product for which to initialize', choices: ['subgraph-studio', 'hosted-service'], skip: () => protocol === 'arweave' || protocol === 'cosmos' || protocol === 'near' || product === 'subgraph-studio' || product === 'hosted-service' || studio !== undefined || node !== undefined, result: (value) => { // For now we only support NEAR subgraphs in the Hosted Service if (protocol === 'near') { // Can be overwritten because the question will be skipped (product === undefined) product = 'hosted-service'; return product; } if (value == 'subgraph-studio') { allowSimpleName = true; } product = value; return value; }, }, { type: 'input', name: 'subgraphName', message: () => (product == 'subgraph-studio' || studio ? 'Subgraph slug' : 'Subgraph name'), initial: subgraphName, validate: (name) => { try { (0, subgraph_1.validateSubgraphName)(name, { allowSimpleName }); return true; } catch (e) { return `${e.message} Examples: $ graph init ${os_1.default.userInfo().username}/${name} $ graph init ${name} --allow-simple-name`; } }, result: (value) => { subgraphName = value; return value; }, }, { type: 'input', name: 'directory', message: 'Directory to create the subgraph in', initial: () => directory || (0, subgraph_1.getSubgraphBasename)( // @ts-expect-error will be set by previous question subgraphName), validate: (value) => gluegun_1.filesystem.exists(value || directory || (0, subgraph_1.getSubgraphBasename)( // @ts-expect-error will be set by previous question subgraphName)) ? 'Directory already exists' : true, }, { type: 'select', name: 'network', message: () => `${protocolInstance.displayName()} network`, choices: () => { initDebug('Generating list of available networks for protocol "%s" (%M)', protocol, availableNetworks.get(protocol)); return ( // @ts-expect-error TODO: wait what? availableNetworks .get(protocol) // Get networks related to the chosen protocol. .toArray()); // Needed because of gluegun. It can't even receive a JS iterable. }, skip: fromExample !== undefined, initial: network || 'mainnet', result: (value) => { network = value; return value; }, }, // TODO: // // protocols that don't support contract // - arweave // - cosmos { type: 'input', name: 'contract', message: () => { ProtocolContract = protocolInstance.getContract(); return `Contract ${ProtocolContract.identifierName()}`; }, skip: () => fromExample !== undefined || !protocolInstance.hasContract(), initial: contract, validate: async (value) => { if (fromExample !== undefined || !protocolInstance.hasContract()) { return true; } // Validate whether the contract is valid const { valid, error } = (0, validation_1.validateContract)(value, ProtocolContract); return valid ? true : error; }, result: async (value) => { if (fromExample !== undefined) { return value; } ABI = protocolInstance.getABI(); // Try loading the ABI from Etherscan, if none was provided if (protocolInstance.hasABIs() && !abi) { try { if (network === 'poa-core') { // TODO: this variable is never used anywhere, what happens? // abiFromBlockScout = await loadAbiFromBlockScout(ABI, network, value) } else { abiFromEtherscan = await (0, abi_1.loadAbiFromEtherscan)(ABI, network, value); } } catch (e) { // noop } } // If startBlock is not set, try to load it. if (!startBlock) { try { // Load startBlock for this contract startBlock = Number(await (0, abi_1.loadStartBlockForContract)(network, value)).toString(); } catch (error) { // noop } } return value; }, }, { type: 'input', name: 'abi', message: 'ABI file (path)', initial: abi, skip: () => !protocolInstance.hasABIs() || fromExample !== undefined || abiFromEtherscan !== undefined, validate: async (value) => { if (fromExample || abiFromEtherscan || !protocolInstance.hasABIs()) { return true; } try { abiFromFile = loadAbiFromFile(ABI, value); return true; } catch (e) { return e.message; } }, }, { type: 'input', name: 'startBlock', message: 'Start Block', initial: () => startBlock || '0', skip: () => fromExample !== undefined, validate: (value) => parseInt(value) >= 0, result: (value) => { startBlock = value; return value; }, }, { type: 'input', name: 'contractName', message: 'Contract Name', initial: contractName || 'Contract', skip: () => fromExample !== undefined || !protocolInstance.hasContract(), validate: (value) => value && value.length > 0, result: (value) => { contractName = value; return value; }, }, { type: 'confirm', name: 'indexEvents', message: 'Index contract events as entities', initial: true, skip: () => !!indexEvents, result: (value) => { indexEvents = value; return value; }, }, ]; try { const answers = await gluegun_1.prompt.ask(questions); return { ...answers, abi: abiFromEtherscan || abiFromFile, protocolInstance, }; } catch (e) { this.error(e, { exit: 1 }); } } const loadAbiFromFile = (ABI, filename) => { const exists = gluegun_1.filesystem.exists(filename); if (!exists) { throw Error('File does not exist.'); } else if (exists === 'dir') { throw Error('Path points to a directory, not a file.'); } else if (exists === 'other') { throw Error('Not sure what this path points to.'); } else { return ABI.load('Contract', filename); } }; function revalidateSubgraphName(subgraphName, { allowSimpleName }) { // Fail if the subgraph name is invalid try { (0, subgraph_1.validateSubgraphName)(subgraphName, { allowSimpleName }); return true; } catch (e) { this.error(`${e.message} Examples: $ graph init ${os_1.default.userInfo().username}/${subgraphName} $ graph init ${subgraphName} --allow-simple-name`); } } const initRepository = async (directory) => await (0, spinner_1.withSpinner)(`Initialize subgraph repository`, `Failed to initialize subgraph repository`, `Warnings while initializing subgraph repository`, async () => { // Remove .git dir in --from-example mode; in --from-contract, we're // starting from an empty directory const gitDir = path_1.default.join(directory, '.git'); if (gluegun_1.filesystem.exists(gitDir)) { gluegun_1.filesystem.remove(gitDir); } await gluegun_1.system.run('git init', { cwd: directory }); await gluegun_1.system.run('git add --all', { cwd: directory }); await gluegun_1.system.run('git commit -m "Initial commit"', { cwd: directory, }); return true; }); const installDependencies = async (directory, commands) => await (0, spinner_1.withSpinner)(`Install dependencies with ${commands.install}`, `Failed to install dependencies`, `Warnings while installing dependencies`, async () => { if (process.env.GRAPH_CLI_TESTS) { await gluegun_1.system.run(commands.link, { cwd: directory }); } await gluegun_1.system.run(commands.install, { cwd: directory }); return true; }); const runCodegen = async (directory, codegenCommand) => await (0, spinner_1.withSpinner)(`Generate ABI and schema types with ${codegenCommand}`, `Failed to generate code from ABI and GraphQL schema`, `Warnings while generating code from ABI and GraphQL schema`, async () => { await gluegun_1.system.run(codegenCommand, { cwd: directory }); return true; }); function printNextSteps({ subgraphName, directory }, { commands, }) { const relativeDir = path_1.default.relative(process.cwd(), directory); // Print instructions this.log(` Subgraph ${subgraphName} created in ${relativeDir} `); this.log(`Next steps: 1. Run \`graph auth\` to authenticate with your deploy key. 2. Type \`cd ${relativeDir}\` to enter the subgraph. 3. Run \`${commands.deploy}\` to deploy the subgraph. Make sure to visit the documentation on https://thegraph.com/docs/ for further information.`); } async function initSubgraphFromExample({ fromExample, allowSimpleName, subgraphName, directory, studio, product, }, { commands, }) { // Fail if the subgraph name is invalid if (!revalidateSubgraphName.bind(this)(subgraphName, { allowSimpleName })) { process.exitCode = 1; return; } // Fail if the output directory already exists if (gluegun_1.filesystem.exists(directory)) { this.error(`Directory or file "${directory}" already exists`, { exit: 1 }); } // Clone the example subgraph repository const cloned = await (0, spinner_1.withSpinner)(`Cloning example subgraph`, `Failed to clone example subgraph`, `Warnings while cloning example subgraph`, async () => { // Create a temporary directory const prefix = path_1.default.join(os_1.default.tmpdir(), 'example-subgraph-'); const tmpDir = fs_1.default.mkdtempSync(prefix); try { await gluegun_1.system.run(`git clone http://github.com/graphprotocol/example-subgraphs ${tmpDir}`); // If an example is not specified, use the default one if (fromExample === undefined || fromExample === true) { fromExample = DEFAULT_EXAMPLE_SUBGRAPH; } const exampleSubgraphPath = path_1.default.join(tmpDir, String(fromExample)); if (!gluegun_1.filesystem.exists(exampleSubgraphPath)) { return { result: false, error: `Example not found: ${fromExample}` }; } gluegun_1.filesystem.copy(exampleSubgraphPath, directory); return true; } finally { gluegun_1.filesystem.remove(tmpDir); } }); if (!cloned) { this.exit(1); return; } try { // It doesn't matter if we changed the URL we clone the YAML, // we'll check it's network anyway. If it's a studio subgraph we're dealing with. const dataSourcesAndTemplates = await DataSourcesExtractor.fromFilePath(path_1.default.join(directory, 'subgraph.yaml')); for (const { network } of dataSourcesAndTemplates) { (0, studio_1.validateStudioNetwork)({ studio, product, network }); } } catch (e) { this.error(e.message, { exit: 1 }); } const networkConf = await (0, network_1.initNetworksConfig)(directory, 'address'); if (networkConf !== true) { this.exit(1); return; } // Update package.json to match the subgraph name const prepared = await (0, spinner_1.withSpinner)(`Update subgraph name and commands in package.json`, `Failed to update subgraph name and commands in package.json`, `Warnings while updating subgraph name and commands in package.json`, async () => { try { // Load package.json const pkgJsonFilename = gluegun_1.filesystem.path(directory, 'package.json'); const pkgJson = await gluegun_1.filesystem.read(pkgJsonFilename, 'json'); pkgJson.name = (0, subgraph_1.getSubgraphBasename)(subgraphName); for (const name of Object.keys(pkgJson.scripts)) { pkgJson.scripts[name] = pkgJson.scripts[name].replace('example', subgraphName); } delete pkgJson['license']; delete pkgJson['repository']; // Remove example's cli in favor of the local one (added via `npm link`) if (process.env.GRAPH_CLI_TESTS) { delete pkgJson['devDependencies']['@graphprotocol/graph-cli']; } // Write package.json gluegun_1.filesystem.write(pkgJsonFilename, pkgJson, { jsonIndent: 2 }); return true; } catch (e) { gluegun_1.filesystem.remove(directory); this.error(`Failed to preconfigure the subgraph: ${e}`); } }); if (!prepared) { this.exit(1); return; } // Initialize a fresh Git repository const repo = await initRepository(directory); if (repo !== true) { this.exit(1); return; } // Install dependencies const installed = await installDependencies(directory, commands); if (installed !== true) { this.exit(1); return; } // Run code-generation const codegen = await runCodegen(directory, commands.codegen); if (codegen !== true) { this.exit(1); return; } printNextSteps.bind(this)({ subgraphName, directory }, { commands }); } async function initSubgraphFromContract({ protocolInstance, allowSimpleName, subgraphName, directory, abi, network, contract, indexEvents, contractName, node, studio, product, startBlock, }, { commands, addContract, }) { // Fail if the subgraph name is invalid if (!revalidateSubgraphName.bind(this)(subgraphName, { allowSimpleName })) { this.exit(1); return; } // Fail if the output directory already exists if (gluegun_1.filesystem.exists(directory)) { this.error(`Directory or file "${directory}" already exists`, { exit: 1 }); } if (protocolInstance.hasABIs() && ((0, schema_1.abiEvents)(abi).size === 0 || // @ts-expect-error TODO: the abiEvents result is expected to be a List, how's it an array? (0, schema_1.abiEvents)(abi).length === 0)) { // Fail if the ABI does not contain any events this.error(`ABI does not contain any events`, { exit: 1 }); } // We can validate this before the scaffold because we receive // the network from the form or via command line argument. // We don't need to read the manifest in this case. try { (0, studio_1.validateStudioNetwork)({ studio, product, network }); } catch (e) { this.error(e, { exit: 1 }); } // Scaffold subgraph const scaffold = await (0, spinner_1.withSpinner)(`Create subgraph scaffold`, `Failed to create subgraph scaffold`, `Warnings while creating subgraph scaffold`, async (spinner) => { const scaffold = await (0, scaffold_1.generateScaffold)({ protocolInstance, subgraphName, abi, network, contract, indexEvents, contractName, startBlock, node, }, spinner); await (0, scaffold_1.writeScaffold)(scaffold, directory, spinner); return true; }); if (scaffold !== true) { process.exitCode = 1; return; } if (protocolInstance.hasContract()) { const identifierName = protocolInstance.getContract().identifierName(); const networkConf = await (0, network_1.initNetworksConfig)(directory, identifierName); if (networkConf !== true) { process.exitCode = 1; return; } } // Initialize a fresh Git repository const repo = await initRepository(directory); if (repo !== true) { this.exit(1); return; } // Install dependencies const installed = await installDependencies(directory, commands); if (installed !== true) { this.exit(1); return; } // Run code-generation const codegen = await runCodegen(directory, commands.codegen); if (codegen !== true) { this.exit(1); return; } while (addContract) { addContract = await addAnotherContract.bind(this)({ protocolInstance, directory }); } printNextSteps.bind(this)({ subgraphName, directory }, { commands }); } async function addAnotherContract({ protocolInstance, directory, }) { const addContractAnswer = await core_1.ux.prompt('Add another contract? (y/n)', { required: true, type: 'single', }); const addContractConfirmation = addContractAnswer.toLowerCase() === 'y'; if (addContractConfirmation) { let abiFromFile = false; const ProtocolContract = protocolInstance.getContract(); let contract = ''; for (;;) { contract = await core_1.ux.prompt(`\nContract ${ProtocolContract.identifierName()}`, { required: true, }); const { valid, error } = (0, validation_1.validateContract)(contract, ProtocolContract); if (valid) { break; } this.log(`✖ ${error}`); } const localAbi = await core_1.ux.prompt('\nProvide local ABI path? (y/n)', { required: true, type: 'single', }); abiFromFile = localAbi.toLowerCase() === 'y'; let abiPath = ''; if (abiFromFile) { abiPath = await core_1.ux.prompt('\nABI file (path)', { required: true }); } const contractName = await core_1.ux.prompt('\nContract Name', { required: true, default: 'Contract', }); // Get the cwd before process.chdir in order to switch back in the end of command execution const cwd = process.cwd(); try { if (fs_1.default.existsSync(directory)) { process.chdir(directory); } const commandLine = [contract, '--contract-name', contractName]; if (abiFromFile) { if (abiPath.includes(directory)) { commandLine.push('--abi', path_1.default.normalize(abiPath.replace(directory, ''))); } else { commandLine.push('--abi', abiPath); } } await add_1.default.run(commandLine); } catch (e) { this.error(e); } finally { // TODO: safer way of doing this? process.chdir(cwd); } } return addContractConfirmation; }