@graphprotocol/graph-cli
Version:
CLI for building for and deploying to The Graph
953 lines (845 loc) • 26.8 kB
JavaScript
const chalk = require('chalk')
const os = require('os')
const path = require('path')
const toolbox = require('gluegun/toolbox')
const fs = require('fs')
const graphCli = require('../cli')
const {
getSubgraphBasename,
validateSubgraphName,
} = require('../command-helpers/subgraph')
const DataSourcesExtractor = require('../command-helpers/data-sources')
const { validateStudioNetwork } = require('../command-helpers/studio')
const { initNetworksConfig } = require('../command-helpers/network')
const { withSpinner, step } = require('../command-helpers/spinner')
const { fixParameters } = require('../command-helpers/gluegun')
const { chooseNodeUrl } = require('../command-helpers/node')
const { loadAbiFromEtherscan, loadAbiFromBlockScout } = require('../command-helpers/abi')
const { generateScaffold, writeScaffold } = require('../command-helpers/scaffold')
const { abiEvents } = require('../scaffold/schema')
const { validateContract } = require('../validation')
const Protocol = require('../protocols')
const protocolChoices = Array.from(Protocol.availableProtocols().keys())
const availableNetworks = Protocol.availableNetworks()
const DEFAULT_EXAMPLE_SUBGRAPH = 'ethereum/gravatar'
let initDebug = require('../debug')('graph-cli:init')
const HELP = `
${chalk.bold('graph init')} [options] [subgraph-name] [directory]
${chalk.dim('Options:')}
--protocol <${protocolChoices.join('|')}>
--product <subgraph-studio|hosted-service>
Selects the product for which to initialize
--studio Shortcut for --product subgraph-studio
-g, --node <node> Graph node for which to initialize
--allow-simple-name Use a subgraph name without a prefix (default: false)
-h, --help Show usage information
${chalk.dim('Choose mode with one of:')}
--from-contract <contract> Creates a scaffold based on an existing contract
--from-example [example] Creates a scaffold based on an example subgraph
${chalk.dim('Options for --from-contract:')}
--contract-name Name of the contract (default: Contract)
--index-events Index contract events as entities
${chalk.dim.underline('Ethereum:')}
--abi <path> Path to the contract ABI (default: download from Etherscan)
--network <${availableNetworks.get('ethereum').join('|')}>
Selects the network the contract is deployed to
${chalk.dim.underline('NEAR:')}
--network <${availableNetworks.get('near').join('|')}>
Selects the network the contract is deployed to
${chalk.dim.underline('Cosmos:')}
--network <${availableNetworks.get('cosmos').join('|')}>
Selects the network the contract is deployed to
`
const processInitForm = async (
toolbox,
{
protocol,
product,
studio,
node,
abi,
allowSimpleName,
directory,
contract,
indexEvents,
fromExample,
network,
subgraphName,
contractName,
},
) => {
let abiFromEtherscan = undefined
let abiFromFile = undefined
let protocolInstance
let ProtocolContract
let ABI
let questions = [
{
type: 'select',
name: 'protocol',
message: 'Protocol',
choices: protocolChoices,
skip: protocolChoices.includes(protocol),
result: value => {
protocol = protocol || value
protocolInstance = new Protocol(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 {
validateSubgraphName(name, { allowSimpleName })
return true
} catch (e) {
return `${e.message}
Examples:
$ graph init ${os.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 || getSubgraphBasename(subgraphName),
validate: value =>
toolbox.filesystem.exists(value || directory || getSubgraphBasename(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 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 } = 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') {
abiFromBlockScout = await loadAbiFromBlockScout(ABI, network, value)
} else {
abiFromEtherscan = await loadAbiFromEtherscan(ABI, network, value)
}
} catch (e) {}
}
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 = await loadAbiFromFile(ABI, value)
return true
} catch (e) {
return e.message
}
},
},
{
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 {
let answers = await toolbox.prompt.ask(questions)
return { ...answers, abi: abiFromEtherscan || abiFromFile, protocolInstance }
} catch (e) {
return undefined
}
}
const loadAbiFromFile = async (ABI, filename) => {
let exists = await toolbox.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 await ABI.load('Contract', filename)
}
}
module.exports = {
description: 'Creates a new subgraph with basic scaffolding',
run: async toolbox => {
// Obtain tools
let { print, system } = toolbox
// Read CLI parameters
let {
protocol,
product,
studio,
node,
g,
abi,
allowSimpleName,
fromContract,
contractName,
fromExample,
h,
help,
indexEvents,
network,
} = toolbox.parameters.options
node = node || g
;({ node, allowSimpleName } = chooseNodeUrl({
product,
studio,
node,
allowSimpleName,
}))
if (fromContract && fromExample) {
print.error(`Only one of --from-example and --from-contract can be used at a time.`)
process.exitCode = 1
return
}
let subgraphName, directory
try {
;[subgraphName, directory] = fixParameters(toolbox.parameters, {
allowSimpleName,
help,
h,
indexEvents,
studio,
})
} catch (e) {
print.error(e.message)
process.exitCode = 1
return
}
// Show help text if requested
if (help || h) {
print.info(HELP)
return
}
// Detect git
let git = await system.which('git')
if (git === null) {
print.error(
`Git was not found on your system. Please install 'git' so it is in $PATH.`,
)
process.exitCode = 1
return
}
// Detect Yarn and/or NPM
let yarn = await system.which('yarn')
let npm = await system.which('npm')
if (!yarn && !npm) {
print.error(
`Neither Yarn nor NPM were found on your system. Please install one of them.`,
)
process.exitCode = 1
return
}
let commands = {
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(
toolbox,
{ fromExample, allowSimpleName, directory, subgraphName, studio, product },
{ commands },
)
}
// 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)) {
print.error(
`Protocol '${protocol}' is not supported, choose from these options: ${protocolChoices.join(
', ',
)}`,
)
process.exitCode = 1
return
}
const protocolInstance = new Protocol(protocol)
if (protocolInstance.hasABIs()) {
const ABI = protocolInstance.getABI()
if (abi) {
try {
abi = await loadAbiFromFile(ABI, abi)
} catch (e) {
print.error(`Failed to load ABI: ${e.message}`)
process.exitCode = 1
return
}
} else {
try {
if (network === 'poa-core') {
abi = await loadAbiFromBlockScout(ABI, network, fromContract)
} else {
abi = await loadAbiFromEtherscan(ABI, network, fromContract)
}
} catch (e) {
process.exitCode = 1
return
}
}
}
return await initSubgraphFromContract(
toolbox,
{
protocolInstance,
abi,
allowSimpleName,
directory,
contract: fromContract,
indexEvents,
network,
subgraphName,
contractName,
node,
studio,
product,
},
{ commands, addContract: false },
)
}
// Otherwise, take the user through the interactive form
let inputs = await processInitForm(toolbox, {
protocol,
product,
studio,
node,
abi,
allowSimpleName,
directory,
contract: fromContract,
indexEvents,
fromExample,
network,
subgraphName,
contractName,
})
// Exit immediately when the form is cancelled
if (inputs === undefined) {
process.exit(1)
}
print.info('———')
if (fromExample) {
await initSubgraphFromExample(
toolbox,
{
fromExample: fromExample,
subgraphName: inputs.subgraphName,
directory: inputs.directory,
studio: inputs.studio,
product: inputs.product,
},
{ commands },
)
} else {
;({ node, allowSimpleName } = chooseNodeUrl({
product: inputs.product,
studio,
node,
allowSimpleName,
}))
await initSubgraphFromContract(
toolbox,
{
protocolInstance: inputs.protocolInstance,
allowSimpleName,
subgraphName: inputs.subgraphName,
directory: inputs.directory,
abi: inputs.abi,
network: inputs.network,
contract: inputs.contract,
indexEvents: inputs.indexEvents,
contractName: inputs.contractName,
node,
studio: inputs.studio,
product: inputs.product,
},
{ commands, addContract: true },
)
}
},
}
const revalidateSubgraphName = async (toolbox, subgraphName, { allowSimpleName }) => {
// Fail if the subgraph name is invalid
try {
validateSubgraphName(subgraphName, { allowSimpleName })
return true
} catch (e) {
toolbox.print.error(`${e.message}
Examples:
$ graph init ${os.userInfo().username}/${subgraphName}
$ graph init ${subgraphName} --allow-simple-name`)
return false
}
}
const initRepository = async (toolbox, directory) =>
await withSpinner(
`Initialize subgraph repository`,
`Failed to initialize subgraph repository`,
`Warnings while initializing subgraph repository`,
async spinner => {
// Remove .git dir in --from-example mode; in --from-contract, we're
// starting from an empty directory
let gitDir = path.join(directory, '.git')
if (toolbox.filesystem.exists(gitDir)) {
await toolbox.filesystem.remove(gitDir)
}
await toolbox.system.run('git init', { cwd: directory })
await toolbox.system.run('git add --all', { cwd: directory })
await toolbox.system.run('git commit -m "Initial commit"', {
cwd: directory,
})
return true
},
)
// Only used for local testing / continuous integration.
//
// This requires that the command `npm link` is called
// on the root directory of this repository, as described here:
// https://docs.npmjs.com/cli/v7/commands/npm-link.
const npmLinkToLocalCli = async (toolbox, directory) => {
if (process.env.GRAPH_CLI_TESTS) {
await toolbox.system.run('npm link @graphprotocol/graph-cli', { cwd: directory })
}
}
const installDependencies = async (toolbox, directory, installCommand) =>
await withSpinner(
`Install dependencies with ${toolbox.print.colors.muted(installCommand)}`,
`Failed to install dependencies`,
`Warnings while installing dependencies`,
async spinner => {
// Links to local graph-cli if we're running the automated tests
await npmLinkToLocalCli(toolbox, directory)
await toolbox.system.run(installCommand, { cwd: directory })
return true
},
)
const runCodegen = async (toolbox, directory, codegenCommand) =>
await withSpinner(
`Generate ABI and schema types with ${toolbox.print.colors.muted(codegenCommand)}`,
`Failed to generate code from ABI and GraphQL schema`,
`Warnings while generating code from ABI and GraphQL schema`,
async spinner => {
await toolbox.system.run(codegenCommand, { cwd: directory })
return true
},
)
const printNextSteps = (toolbox, { subgraphName, directory }, { commands }) => {
let { print } = toolbox
let relativeDir = path.relative(process.cwd(), directory)
// Print instructions
print.success(
`
Subgraph ${print.colors.blue(subgraphName)} created in ${print.colors.blue(relativeDir)}
`,
)
print.info(`Next steps:
1. Run \`${print.colors.muted('graph auth')}\` to authenticate with your deploy key.
2. Type \`${print.colors.muted(`cd ${relativeDir}`)}\` to enter the subgraph.
3. Run \`${print.colors.muted(commands.deploy)}\` to deploy the subgraph.
Make sure to visit the documentation on https://thegraph.com/docs/ for further information.`)
}
const initSubgraphFromExample = async (
toolbox,
{ fromExample, allowSimpleName, subgraphName, directory, studio, product },
{ commands },
) => {
let { filesystem, print, system } = toolbox
// Fail if the subgraph name is invalid
if (!revalidateSubgraphName(toolbox, subgraphName, { allowSimpleName })) {
process.exitCode = 1
return
}
// Fail if the output directory already exists
if (filesystem.exists(directory)) {
print.error(`Directory or file "${directory}" already exists`)
process.exitCode = 1
return
}
// Clone the example subgraph repository
let cloned = await withSpinner(
`Cloning example subgraph`,
`Failed to clone example subgraph`,
`Warnings while cloning example subgraph`,
async spinner => {
// Create a temporary directory
const prefix = path.join(os.tmpdir(), 'example-subgraph-')
const tmpDir = fs.mkdtempSync(prefix)
try {
await 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.join(tmpDir, fromExample)
if (!filesystem.exists(exampleSubgraphPath)) {
return { result: false, error: `Example not found: ${fromExample}` }
}
filesystem.copy(exampleSubgraphPath, directory)
return true
} finally {
filesystem.remove(tmpDir)
}
},
)
if (!cloned) {
process.exitCode = 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.join(directory, 'subgraph.yaml'),
)
for (const { network } of dataSourcesAndTemplates) {
validateStudioNetwork({ studio, product, network })
}
} catch (e) {
print.error(e.message)
process.exitCode = 1
return
}
let networkConf = await initNetworksConfig(toolbox, directory, 'address')
if (networkConf !== true) {
process.exitCode = 1
return
}
// Update package.json to match the subgraph name
let prepared = await 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 spinner => {
try {
// Load package.json
let pkgJsonFilename = filesystem.path(directory, 'package.json')
let pkgJson = await filesystem.read(pkgJsonFilename, 'json')
pkgJson.name = getSubgraphBasename(subgraphName)
Object.keys(pkgJson.scripts).forEach(name => {
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
await filesystem.write(pkgJsonFilename, pkgJson, { jsonIndent: 2 })
return true
} catch (e) {
print.error(`Failed to preconfigure the subgraph: ${e}`)
filesystem.remove(directory)
return false
}
},
)
if (!prepared) {
process.exitCode = 1
return
}
// Initialize a fresh Git repository
let repo = await initRepository(toolbox, directory)
if (repo !== true) {
process.exitCode = 1
return
}
// Install dependencies
let installed = await installDependencies(toolbox, directory, commands.install)
if (installed !== true) {
process.exitCode = 1
return
}
// Run code-generation
let codegen = await runCodegen(toolbox, directory, commands.codegen)
if (codegen !== true) {
process.exitCode = 1
return
}
printNextSteps(toolbox, { subgraphName, directory }, { commands })
}
const initSubgraphFromContract = async (
toolbox,
{
protocolInstance,
allowSimpleName,
subgraphName,
directory,
abi,
network,
contract,
indexEvents,
contractName,
node,
studio,
product,
},
{ commands, addContract },
) => {
let { print } = toolbox
// Fail if the subgraph name is invalid
if (!revalidateSubgraphName(toolbox, subgraphName, { allowSimpleName })) {
process.exitCode = 1
return
}
// Fail if the output directory already exists
if (toolbox.filesystem.exists(directory)) {
print.error(`Directory or file "${directory}" already exists`)
process.exitCode = 1
return
}
if (protocolInstance.hasABIs() && abiEvents(abi).length === 0) {
// Fail if the ABI does not contain any events
print.error(`ABI does not contain any events`)
process.exitCode = 1
return
}
// 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 {
validateStudioNetwork({ studio, product, network })
} catch (e) {
print.error(e.message)
process.exitCode = 1
return
}
// Scaffold subgraph
let scaffold = await withSpinner(
`Create subgraph scaffold`,
`Failed to create subgraph scaffold`,
`Warnings while creating subgraph scaffold`,
async spinner => {
let scaffold = await generateScaffold(
{
protocolInstance,
subgraphName,
abi,
network,
contract,
indexEvents,
contractName,
node,
},
spinner,
)
await writeScaffold(scaffold, directory, spinner)
return true
},
)
if (scaffold !== true) {
process.exitCode = 1
return
}
if (protocolInstance.hasContract()) {
let identifierName = protocolInstance.getContract().identifierName()
let networkConf = await initNetworksConfig(toolbox, directory, identifierName)
if (networkConf !== true) {
process.exitCode = 1
return
}
}
// Initialize a fresh Git repository
let repo = await initRepository(toolbox, directory)
if (repo !== true) {
process.exitCode = 1
return
}
// Install dependencies
let installed = await installDependencies(toolbox, directory, commands.install)
if (installed !== true) {
process.exitCode = 1
return
}
// Run code-generation
let codegen = await runCodegen(toolbox, directory, commands.codegen)
if (codegen !== true) {
process.exitCode = 1
return
}
while (addContract) {
addContract = await addAnotherContract(toolbox, { protocolInstance, directory })
}
printNextSteps(toolbox, { subgraphName, directory }, { commands })
}
const addAnotherContract = async (toolbox, { protocolInstance, directory }) => {
const addContractConfirmation = await toolbox.prompt.confirm('Add another contract?')
if (addContractConfirmation) {
let abiFromFile
let ProtocolContract = protocolInstance.getContract()
let questions = [
{
type: 'input',
name: 'contract',
message: () => `Contract ${ProtocolContract.identifierName()}`,
validate: async value => {
// Validate whether the contract is valid
const { valid, error } = validateContract(value, ProtocolContract)
return valid ? true : error
},
},
{
type: 'select',
name: 'localAbi',
message: 'Provide local ABI path?',
choices: ['yes', 'no'],
result: value => {
abiFromFile = value === 'yes' ? true : false
return abiFromFile
},
},
{
type: 'input',
name: 'abi',
message: 'ABI file (path)',
skip: () => abiFromFile === false,
},
{
type: 'input',
name: 'contractName',
message: 'Contract Name',
initial: 'Contract',
validate: value => value && value.length > 0,
},
]
// Get the cwd before process.chdir in order to switch back in the end of command execution
const cwd = process.cwd()
try {
let { abi, contract, contractName } = await toolbox.prompt.ask(questions)
if (fs.existsSync(directory)) {
process.chdir(directory)
}
let commandLine = ['add', contract, '--contract-name', contractName]
if (abiFromFile) {
if (abi.includes(directory)) {
commandLine.push('--abi', path.normalize(abi.replace(directory, '')))
} else {
commandLine.push('--abi', abi)
}
}
await graphCli.run(commandLine)
} catch (e) {
toolbox.print.error(e)
process.exit(1)
} finally {
process.chdir(cwd)
}
}
return addContractConfirmation
}