Skip to content

Commit

Permalink
Various CLI fixes and UX improvements (#2952)
Browse files Browse the repository at this point in the history
### Description

- Fixes hyperlane-xyz/issues#747
- Fixes hyperlane-xyz/issues#746
- Fixes hyperlane-xyz/issues#730
- Fixes hyperlane-xyz/issues#727
- Fixes hyperlane-xyz/issues#729
- Fixes #2234
- Fixes hyperlane-xyz/issues#728

### Drive-by changes

Make chai and mocha versions consistent across monorepo

### Backward compatibility

Yes

### Testing

Setup unit testing for CLI and run as part of CI jobs
  • Loading branch information
jmrossy authored Nov 22, 2023
1 parent 31f60be commit 97f4c94
Show file tree
Hide file tree
Showing 21 changed files with 159 additions and 146 deletions.
5 changes: 5 additions & 0 deletions .changeset/shy-apes-provide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperlane-xyz/cli': patch
---

Various user experience improvements
2 changes: 1 addition & 1 deletion solidity/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"@nomiclabs/hardhat-waffle": "^2.0.6",
"@typechain/ethers-v5": "^10.0.0",
"@typechain/hardhat": "^6.0.0",
"chai": "^4.3.0",
"chai": "^4.3.6",
"ethereum-waffle": "^4.0.10",
"ethers": "^5.7.2",
"hardhat": "^2.19.0",
Expand Down
8 changes: 8 additions & 0 deletions typescript/cli/.mocharc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extensions": ["ts"],
"spec": ["src/**/*.test.*"],
"node-option": [
"experimental-specifier-resolution=node",
"loader=ts-node/esm"
]
}
6 changes: 4 additions & 2 deletions typescript/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,24 @@ import { configCommand } from './src/commands/config.js';
import { deployCommand } from './src/commands/deploy.js';
import { sendCommand } from './src/commands/send.js';
import { statusCommand } from './src/commands/status.js';
import { readJson } from './src/utils/files.js';

// From yargs code:
const MISSING_PARAMS_ERROR = 'Not enough non-option arguments';

console.log(chalk.blue('Hyperlane'), chalk.magentaBright('CLI'));

try {
const version = readJson<any>('./package.json').version;

await yargs(process.argv.slice(2))
.scriptName('hyperlane')
// TODO get version num from package.json
.version(false)
.command(chainsCommand)
.command(configCommand)
.command(deployCommand)
.command(sendCommand)
.command(statusCommand)
.version(version)
.demandCommand()
.strict()
.help()
Expand Down
6 changes: 6 additions & 0 deletions typescript/cli/examples/chain-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# Consists of a map of chain names to metadata
# Schema here: https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/typescript/sdk/src/metadata/chainMetadataTypes.ts
---
# You can define a full config for a new chain
mychainname:
# Required fields:
chainId: 1234567890 # Number: Use EIP-155 for EVM chains
Expand Down Expand Up @@ -42,3 +43,8 @@ mychainname:
estimateBlockTime: 15 # Number: Rough estimate of time per block in seconds
# transactionOverrides: # Object: Properties to include when forming transaction requests
# Any tx fields are allowed

# Alternatively, you can extend a core chain config with only fields to be overridden
sepolia:
rpcUrls:
- http: https://mycustomrpc.com
1 change: 1 addition & 0 deletions typescript/cli/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const logPink = (...args: any) =>
export const logGray = (...args: any) => console.log(chalk.gray(...args));
export const logGreen = (...args: any) => console.log(chalk.green(...args));
export const logRed = (...args: any) => console.log(chalk.red(...args));
export const logTip = (...args: any) => console.log(chalk.bgYellow(...args));
export const errorRed = (...args: any) => console.error(chalk.red(...args));
export const log = (...args: any) => console.log(...args);
export const logTable = (...args: any) => console.table(...args);
12 changes: 9 additions & 3 deletions typescript/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"description": "A command-line utility for common Hyperlane operations",
"dependencies": {
"@hyperlane-xyz/sdk": "3.1.7",
"@hyperlane-xyz/utils": "3.1.7",
"@inquirer/prompts": "^3.0.0",
"bignumber.js": "^9.1.1",
"chalk": "^5.3.0",
Expand All @@ -14,26 +15,31 @@
"zod": "^3.21.2"
},
"devDependencies": {
"@types/mocha": "^10.0.1",
"@types/node": "^18.14.5",
"@types/yargs": "^17.0.24",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"chai": "^4.3.6",
"eslint": "^8.43.0",
"eslint-config-prettier": "^8.8.0",
"mocha": "^10.2.0",
"prettier": "^2.8.8",
"typescript": "^5.1.6"
},
"scripts": {
"hyperlane": "node ./dist/cli.js",
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf ./dist",
"dev": "tsc --watch",
"lint": "eslint . --ext .ts",
"prettier": "prettier --write ./src ./examples"
"prettier": "prettier --write ./src ./examples",
"test": "mocha --config .mocharc.json"
},
"files": [
"./dist",
"./examples"
"./examples",
"package.json"
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
18 changes: 18 additions & 0 deletions typescript/cli/src/config/chain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { expect } from 'chai';

import { readChainConfigs } from './chain.js';

describe('readChainConfigs', () => {
const chainToMetadata = readChainConfigs('./examples/chain-config.yaml');

it('parses and validates correctly', () => {
expect(chainToMetadata.mychainname.chainId).to.equal(1234567890);
});

it('merges core configs', () => {
expect(chainToMetadata.sepolia.chainId).to.equal(11155111);
expect(chainToMetadata.sepolia.rpcUrls[0].http).to.equal(
'https://mycustomrpc.com',
);
});
});
29 changes: 21 additions & 8 deletions typescript/cli/src/config/chain.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { confirm, input, select } from '@inquirer/prompts';
import fs from 'fs';

import {
ChainMap,
ChainMetadata,
ChainMetadataSchema,
chainMetadata as coreChainMetadata,
} from '@hyperlane-xyz/sdk';
import { ProtocolType } from '@hyperlane-xyz/utils';
import { ProtocolType, objMerge } from '@hyperlane-xyz/utils';

import { errorRed, log, logBlue, logGreen } from '../../logger.js';
import { getMultiProvider } from '../context.js';
import { FileFormat, mergeYamlOrJson, readYamlOrJson } from '../utils/files.js';
import {
FileFormat,
isFile,
mergeYamlOrJson,
readYamlOrJson,
} from '../utils/files.js';

export function readChainConfigs(filePath: string) {
log(`Reading file configs in ${filePath}`);
Expand All @@ -25,30 +30,38 @@ export function readChainConfigs(filePath: string) {
process.exit(1);
}

for (const [chain, metadata] of Object.entries(chainToMetadata)) {
const parseResult = ChainMetadataSchema.safeParse(metadata);
// Validate configs from file and merge in core configs as needed
for (const chain of Object.keys(chainToMetadata)) {
if (coreChainMetadata[chain]) {
// For core chains, merge in the default config to allow users to override only some fields
chainToMetadata[chain] = objMerge(
coreChainMetadata[chain],
chainToMetadata[chain],
);
}
const parseResult = ChainMetadataSchema.safeParse(chainToMetadata[chain]);
if (!parseResult.success) {
errorRed(
`Chain config for ${chain} is invalid, please see https://github.com/hyperlane-xyz/hyperlane-monorepo/blob/main/typescript/cli/examples/chain-config.yaml for an example`,
);
errorRed(JSON.stringify(parseResult.error.errors));
process.exit(1);
}
if (metadata.name !== chain) {
if (chainToMetadata[chain].name !== chain) {
errorRed(`Chain ${chain} name does not match key`);
process.exit(1);
}
}

// Ensure multiprovider accepts this metadata
// Ensure MultiProvider accepts this metadata
getMultiProvider(chainToMetadata);

logGreen(`All chain configs in ${filePath} are valid`);
return chainToMetadata;
}

export function readChainConfigsIfExists(filePath: string) {
if (!fs.existsSync(filePath)) {
if (!isFile(filePath)) {
log('No chain config file provided');
return {};
} else {
Expand Down
2 changes: 1 addition & 1 deletion typescript/cli/src/config/warp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export async function createWarpConfig({

const syntheticChains = await runMultiChainSelectionStep(
customChains,
'Select the chains to which the base token will be connected',
'Select chains to which the base token will be connected',
);

// TODO add more prompts here to support customizing the token metadata
Expand Down
2 changes: 1 addition & 1 deletion typescript/cli/src/deploy/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export async function runCoreDeploy({
if (!chains?.length) {
chains = await runMultiChainSelectionStep(
customChains,
'Select chains to which core contacts will be deployed',
'Select chains to connect',
);
}
const artifacts = await runArtifactStep(chains, artifactsPath);
Expand Down
24 changes: 11 additions & 13 deletions typescript/cli/src/deploy/warp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
getMergedContractAddresses,
} from '../context.js';
import {
isFile,
prepNewArtifactsFiles,
runFileSelectionStep,
writeJson,
Expand All @@ -53,12 +54,14 @@ export async function runWarpDeploy({
}) {
const { multiProvider, signer } = getContextWithSigner(key, chainConfigPath);

if (!warpConfigPath) {
if (!warpConfigPath || !isFile(warpConfigPath)) {
warpConfigPath = await runFileSelectionStep(
'./configs',
'Warp config',
'warp',
);
} else {
log(`Using warp config at ${warpConfigPath}`);
}
const warpRouteConfig = readWarpRouteConfig(warpConfigPath);

Expand Down Expand Up @@ -115,11 +118,6 @@ async function runBuildConfigStep({

const mergedContractAddrs = getMergedContractAddresses(artifacts);

logGray(
'Contract addresses from artifacts:\n',
JSON.stringify(mergedContractAddrs[baseChainName], null, 4),
);

// Create configs that coalesce together values from the config file,
// the artifacts, and the SDK as a fallback
const configMap: ChainMap<TokenConfig & RouterConfig> = {
Expand All @@ -130,12 +128,12 @@ async function runBuildConfigStep({
? base.address!
: ethers.constants.AddressZero,
owner,
mailbox: base.mailbox || mergedContractAddrs[baseChainName].mailbox,
mailbox: base.mailbox || mergedContractAddrs[baseChainName]?.mailbox,
interchainSecurityModule:
base.interchainSecurityModule ||
mergedContractAddrs[baseChainName].interchainSecurityModule ||
mergedContractAddrs[baseChainName].multisigIsm,
// ismFactory: mergedContractAddrs[baseChainName].routingIsmFactory, // fix when updating from routingIsm
mergedContractAddrs[baseChainName]?.interchainSecurityModule ||
mergedContractAddrs[baseChainName]?.multisigIsm,
// ismFactory: mergedContractAddrs[baseChainName].routingIsmFactory, // TODO fix when updating from routingIsm
foreignDeployment: base.foreignDeployment,
name: baseMetadata.name,
symbol: baseMetadata.symbol,
Expand All @@ -154,9 +152,9 @@ async function runBuildConfigStep({
mailbox: synthetic.mailbox || mergedContractAddrs[sChainName].mailbox,
interchainSecurityModule:
synthetic.interchainSecurityModule ||
mergedContractAddrs[sChainName].interchainSecurityModule ||
mergedContractAddrs[sChainName].multisigIsm,
// ismFactory: mergedContractAddrs[sChainName].routingIsmFactory, // fix
mergedContractAddrs[sChainName]?.interchainSecurityModule ||
mergedContractAddrs[sChainName]?.multisigIsm,
// ismFactory: mergedContractAddrs[sChainName].routingIsmFactory, // TODO fix
foreignDeployment: synthetic.foreignDeployment,
};
}
Expand Down
21 changes: 12 additions & 9 deletions typescript/cli/src/utils/chains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
testnetChainsMetadata,
} from '@hyperlane-xyz/sdk';

import { log, logBlue } from '../../logger.js';
import { log, logBlue, logRed, logTip } from '../../logger.js';

// A special value marker to indicate user selected
// a new chain in the list
Expand All @@ -34,14 +34,17 @@ export async function runMultiChainSelectionStep(
message = 'Select chains',
) {
const choices = getChainChoices(customChains);
const chains = (await checkbox({
message,
choices,
pageSize: 20,
})) as string[];
handleNewChain(chains);
if (!chains?.length) throw new Error('No chains selected');
return chains;
while (true) {
logTip('Use SPACE key to select chains, then press ENTER');
const chains = (await checkbox({
message,
choices,
pageSize: 20,
})) as string[];
handleNewChain(chains);
if (chains?.length >= 2) return chains;
else logRed('Please select at least 2 chains');
}
}

function getChainChoices(customChains: ChainMap<ChainMetadata>) {
Expand Down
20 changes: 15 additions & 5 deletions typescript/cli/src/utils/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,32 @@ import { parse as yamlParse, stringify as yamlStringify } from 'yaml';

import { objMerge } from '@hyperlane-xyz/utils';

import { logBlue } from '../../logger.js';
import { log, logBlue } from '../../logger.js';

import { getTimestampForFilename } from './time.js';

export type FileFormat = 'yaml' | 'json';

export function isFile(filepath: string) {
if (!filepath) return false;
try {
return fs.existsSync(filepath) && fs.lstatSync(filepath).isFile();
} catch (error) {
log(`Error checking for file: ${filepath}`);
return false;
}
}

export function readFileAtPath(filepath: string) {
if (!fs.existsSync(filepath)) {
if (!isFile(filepath)) {
throw Error(`File doesn't exist at ${filepath}`);
}
return fs.readFileSync(filepath, 'utf8');
}

export function writeFileAtPath(filepath: string, value: string) {
const dirname = path.dirname(filepath);
if (!fs.existsSync(dirname)) {
if (!isFile(dirname)) {
fs.mkdirSync(dirname, { recursive: true });
}
fs.writeFileSync(filepath, value);
Expand All @@ -47,7 +57,7 @@ export function mergeJson<T extends Record<string, any>>(
filepath: string,
obj: T,
) {
if (fs.existsSync(filepath)) {
if (isFile(filepath)) {
const previous = readJson<T>(filepath);
writeJson(filepath, objMerge(previous, obj));
} else {
Expand Down Expand Up @@ -75,7 +85,7 @@ export function mergeYaml<T extends Record<string, any>>(
filepath: string,
obj: T,
) {
if (fs.existsSync(filepath)) {
if (isFile(filepath)) {
const previous = readYaml<T>(filepath);
writeYaml(filepath, objMerge(previous, obj));
} else {
Expand Down
10 changes: 10 additions & 0 deletions typescript/cli/src/utils/time.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { expect } from 'chai';

import { getTimestampForFilename } from './time.js';

describe('getTimestampForFilename', () => {
it('structures timestamp correctly', () => {
const filename = getTimestampForFilename();
expect(filename).to.match(/\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}/);
});
});
Loading

0 comments on commit 97f4c94

Please sign in to comment.