feat: better chain selection concept (#4596)

### Description

This PR implements an updated view of the multi-chain selection step
that now allows searching for chains in the current list

#### Before:


![image](https://github.com/user-attachments/assets/64876be9-16f6-4c23-8562-637776d1db0a)


![image](https://github.com/user-attachments/assets/165c46c5-e94a-48b6-aa7c-38a68b20eed7)


#### After:


![image](https://github.com/user-attachments/assets/1c91c34f-c7aa-43df-8de6-7e7322c1ba70)


![image](https://github.com/user-attachments/assets/adfac628-a9c2-4c28-85a2-853dea1da551)


![image](https://github.com/user-attachments/assets/809fef22-9a2a-4220-8192-9108ae1e093e)

### Drive-by changes

- Updated the `runMultiChainSelectionStep` function to take as param an
object instead of a list of params because the list was growing larger

### Related issues

- Fixes https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/4513

### Backward compatibility

- Yes

### Testing

- Manual
- Manual testing has also been conducted on Windows to see if
https://github.com/hyperlane-xyz/hyperlane-monorepo/issues/4508 was
solved. In this case, the following were discovered:
    - The chain selection is unusable on `gitbash`.
- The chain selection works perfectly fine using `powershell` and `cmd`.
I assume the issue is linked to how `gitbash` handles inputs or
simulates a UNIX environment on Windows. CLI users on windows should use
either one of these options
pull/4707/head
xeno097 1 month ago committed by GitHub
parent 3f48f5a8ed
commit 01e7070ebe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      .changeset/tricky-mangos-sin.md
  2. 1
      typescript/cli/package.json
  3. 10
      typescript/cli/src/config/hooks.ts
  4. 20
      typescript/cli/src/config/ism.ts
  5. 4
      typescript/cli/src/config/multisig.ts
  6. 11
      typescript/cli/src/config/warp.ts
  7. 10
      typescript/cli/src/deploy/agent.ts
  8. 108
      typescript/cli/src/utils/chains.ts
  9. 509
      typescript/cli/src/utils/input.ts
  10. 17
      yarn.lock

@ -0,0 +1,5 @@
---
'@hyperlane-xyz/cli': minor
---
updates the multi chain selection prompt by adding search functionality and an optional confirmation prompt for the current selection

@ -9,6 +9,7 @@
"@hyperlane-xyz/sdk": "5.5.0",
"@hyperlane-xyz/utils": "5.5.0",
"@inquirer/prompts": "^3.0.0",
"ansi-escapes": "^7.0.0",
"asn1.js": "^5.4.1",
"bignumber.js": "^9.1.1",
"chalk": "^5.3.0",

@ -265,11 +265,11 @@ export const createRoutingConfig = callWithConfigCreationLogs(
message: 'Enter owner address for routing Hook',
});
const ownerAddress = owner;
const chains = await runMultiChainSelectionStep(
context.chainMetadata,
'Select chains for routing Hook',
1,
);
const chains = await runMultiChainSelectionStep({
chainMetadata: context.chainMetadata,
message: 'Select chains for routing Hook',
requireNumber: 1,
});
const domainsMap: ChainMap<HookConfig> = {};
for (const chain of chains) {

@ -226,11 +226,11 @@ export const createRoutingConfig = callWithConfigCreationLogs(
message: 'Enter owner address for routing ISM',
});
const ownerAddress = owner;
const chains = await runMultiChainSelectionStep(
context.chainMetadata,
'Select chains to configure routing ISM for',
1,
);
const chains = await runMultiChainSelectionStep({
chainMetadata: context.chainMetadata,
message: 'Select chains to configure routing ISM for',
requireNumber: 1,
});
const domainsMap: ChainMap<IsmConfig> = {};
for (const chain of chains) {
@ -249,11 +249,11 @@ export const createRoutingConfig = callWithConfigCreationLogs(
export const createFallbackRoutingConfig = callWithConfigCreationLogs(
async (context: CommandContext): Promise<IsmConfig> => {
const chains = await runMultiChainSelectionStep(
context.chainMetadata,
'Select chains to configure fallback routing ISM for',
1,
);
const chains = await runMultiChainSelectionStep({
chainMetadata: context.chainMetadata,
message: 'Select chains to configure fallback routing ISM for',
requireNumber: 1,
});
const domainsMap: ChainMap<IsmConfig> = {};
for (const chain of chains) {

@ -72,7 +72,9 @@ export async function createMultisigConfig({
log(
'Select your own chain below to run your own validators. If you want to reuse existing Hyperlane validators instead of running your own, do not select additional mainnet or testnet chains.',
);
const chains = await runMultiChainSelectionStep(context.chainMetadata);
const chains = await runMultiChainSelectionStep({
chainMetadata: context.chainMetadata,
});
const chainAddresses = await context.registry.getAddresses();
const result: MultisigConfigMap = {};

@ -125,11 +125,12 @@ export async function createWarpRouteDeployConfig({
'signer',
);
const warpChains = await runMultiChainSelectionStep(
context.chainMetadata,
'Select chains to connect',
1,
);
const warpChains = await runMultiChainSelectionStep({
chainMetadata: context.chainMetadata,
message: 'Select chains to connect',
requireNumber: 1,
requiresConfirmation: true,
});
const result: WarpRouteDeployConfig = {};
let typeChoices = TYPE_CHOICES;

@ -28,11 +28,11 @@ export async function runKurtosisAgentDeploy({
);
}
if (!relayChains) {
const selectedRelayChains = await runMultiChainSelectionStep(
context.chainMetadata,
'Select chains to relay between',
2,
);
const selectedRelayChains = await runMultiChainSelectionStep({
chainMetadata: context.chainMetadata,
message: 'Select chains to relay between',
requireNumber: 2,
});
relayChains = selectedRelayChains.join(',');
}

@ -1,13 +1,14 @@
import { Separator, checkbox } from '@inquirer/prompts';
import { Separator, confirm } from '@inquirer/prompts';
import select from '@inquirer/select';
import chalk from 'chalk';
import { ChainMap, ChainMetadata } from '@hyperlane-xyz/sdk';
import { toTitleCase } from '@hyperlane-xyz/utils';
import { log, logRed, logTip } from '../logger.js';
import { log } from '../logger.js';
import { calculatePageSize } from './cli-options.js';
import { SearchableCheckboxChoice, searchableCheckBox } from './input.js';
// A special value marker to indicate user selected
// a new chain in the list
@ -18,37 +19,101 @@ export async function runSingleChainSelectionStep(
message = 'Select chain',
) {
const networkType = await selectNetworkType();
const choices = getChainChoices(chainMetadata, networkType);
const { choices, networkTypeSeparator } = getChainChoices(
chainMetadata,
networkType,
);
const chain = (await select({
message,
choices,
choices: [networkTypeSeparator, ...choices],
pageSize: calculatePageSize(2),
})) as string;
handleNewChain([chain]);
return chain;
}
export async function runMultiChainSelectionStep(
chainMetadata: ChainMap<ChainMetadata>,
type RunMultiChainSelectionStepOptions = {
/**
* The metadata of the chains that will be displayed to the user
*/
chainMetadata: ChainMap<ChainMetadata>;
/**
* The message to display to the user
*
* @default 'Select chains'
*/
message?: string;
/**
* The minimum number of chains that must be selected
*
* @default 0
*/
requireNumber?: number;
/**
* Whether to ask for confirmation after the selection
*
* @default false
*/
requiresConfirmation?: boolean;
};
export async function runMultiChainSelectionStep({
chainMetadata,
message = 'Select chains',
requireNumber = 0,
) {
requiresConfirmation = false,
}: RunMultiChainSelectionStepOptions) {
const networkType = await selectNetworkType();
const choices = getChainChoices(chainMetadata, networkType);
const { choices, networkTypeSeparator } = getChainChoices(
chainMetadata,
networkType,
);
let currentChoiceSelection = new Set();
while (true) {
logTip(
`Use SPACE key to select at least ${requireNumber} chains, then press ENTER`,
);
const chains = (await checkbox({
const chains = await searchableCheckBox({
message,
choices,
selectableOptionsSeparator: networkTypeSeparator,
choices: choices.map((choice) =>
currentChoiceSelection.has(choice.name)
? { ...choice, checked: true }
: choice,
),
instructions: `Use TAB key to select at least ${requireNumber} chains, then press ENTER to proceed. Type to search for a specific chain.`,
theme: {
style: {
// The leading space is needed because the help tip will be tightly close to the message header
helpTip: (text: string) => ` ${chalk.bgYellow(text)}`,
},
helpMode: 'always',
},
pageSize: calculatePageSize(2),
})) as string[];
validate: (answer): string | boolean => {
if (answer.length < requireNumber) {
return `Please select at least ${requireNumber} chains`;
}
return true;
},
});
handleNewChain(chains);
if (chains?.length < requireNumber) {
logRed(`Please select at least ${requireNumber} chains`);
const confirmed = requiresConfirmation
? await confirm({
message: `Is this chain selection correct?: ${chalk.cyan(
chains.join(', '),
)}`,
})
: true;
if (!confirmed) {
currentChoiceSelection = new Set(chains);
continue;
}
return chains;
}
}
@ -75,12 +140,17 @@ function getChainChoices(
const filteredChains = chains.filter((c) =>
networkType === 'mainnet' ? !c.isTestnet : !!c.isTestnet,
);
const choices: Parameters<typeof select>['0']['choices'] = [
const choices: SearchableCheckboxChoice<string>[] = [
{ name: '(New custom chain)', value: NEW_CHAIN_MARKER },
new Separator(`--${toTitleCase(networkType)} Chains--`),
...chainsToChoices(filteredChains),
];
return choices;
return {
choices,
networkTypeSeparator: new Separator(
`--${toTitleCase(networkType)} Chains--`,
),
};
}
function handleNewChain(chainNames: string[]) {

@ -1,4 +1,22 @@
import { confirm, input } from '@inquirer/prompts';
import {
Separator,
type Theme,
createPrompt,
isEnterKey,
makeTheme,
useEffect,
useKeypress,
useMemo,
usePagination,
usePrefix,
useRef,
useState,
} from '@inquirer/core';
import figures from '@inquirer/figures';
import { KeypressEvent, confirm, input } from '@inquirer/prompts';
import type { PartialDeep } from '@inquirer/type';
import ansiEscapes from 'ansi-escapes';
import chalk from 'chalk';
import { logGray } from '../logger.js';
@ -53,3 +71,492 @@ export async function inputWithInfo({
} while (answer === INFO_COMMAND);
return answer;
}
/**
* Searchable checkbox code
*
* Note that the code below hab been implemented by taking inspiration from
* the @inquirer/prompt package search and checkbox prompts
*
* - https://github.com/SBoudrias/Inquirer.js/blob/main/packages/search/src/index.mts
* - https://github.com/SBoudrias/Inquirer.js/blob/main/packages/checkbox/src/index.mts
*/
type Status = 'loading' | 'idle' | 'done';
type SearchableCheckboxTheme = {
icon: {
checked: string;
unchecked: string;
cursor: string;
};
style: {
disabledChoice: (text: string) => string;
renderSelectedChoices: <T>(
selectedChoices: ReadonlyArray<NormalizedChoice<T>>,
allChoices: ReadonlyArray<NormalizedChoice<T> | Separator>,
) => string;
description: (text: string) => string;
helpTip: (text: string) => string;
};
helpMode: 'always' | 'never' | 'auto';
};
const checkboxTheme: SearchableCheckboxTheme = {
icon: {
checked: chalk.green(figures.circleFilled),
unchecked: figures.circle,
cursor: figures.pointer,
},
style: {
disabledChoice: (text: string) => chalk.dim(`- ${text}`),
renderSelectedChoices: (selectedChoices) =>
selectedChoices.map((choice) => choice.short).join(', '),
description: (text: string) => chalk.cyan(text),
helpTip: (text) => ` ${text}`,
},
helpMode: 'always',
};
export type SearchableCheckboxChoice<Value> = {
value: Value;
name?: string;
description?: string;
short?: string;
disabled?: boolean | string;
checked?: boolean;
};
type NormalizedChoice<Value> = Required<
Omit<SearchableCheckboxChoice<Value>, 'description'>
> & {
description?: string;
};
type SearchableCheckboxConfig<Value> = {
message: string;
prefix?: string;
pageSize?: number;
instructions?: string;
choices: ReadonlyArray<SearchableCheckboxChoice<Value>>;
loop?: boolean;
required?: boolean;
selectableOptionsSeparator?: Separator;
validate?: (
choices: ReadonlyArray<SearchableCheckboxChoice<Value>>,
) => boolean | string | Promise<string | boolean>;
theme?: PartialDeep<Theme<SearchableCheckboxTheme>>;
};
type Item<Value> = NormalizedChoice<Value> | Separator;
type SearchableCheckboxState<Value> = {
options: Item<Value>[];
currentOptionState: Record<string, NormalizedChoice<Value>>;
};
function isSelectable<Value>(
item: Item<Value>,
): item is NormalizedChoice<Value> {
return !Separator.isSeparator(item) && !item.disabled;
}
function isChecked<Value>(item: Item<Value>): item is NormalizedChoice<Value> {
return isSelectable(item) && Boolean(item.checked);
}
function toggle<Value>(item: Item<Value>): Item<Value> {
return isSelectable(item) ? { ...item, checked: !item.checked } : item;
}
function normalizeChoices<Value>(
choices: ReadonlyArray<SearchableCheckboxChoice<Value>>,
): NormalizedChoice<Value>[] {
return choices.map((choice) => {
const name = choice.name ?? String(choice.value);
return {
value: choice.value,
name,
short: choice.short ?? name,
description: choice.description,
disabled: choice.disabled ?? false,
checked: choice.checked ?? false,
};
});
}
function sortNormalizedItems<Value>(
a: NormalizedChoice<Value>,
b: NormalizedChoice<Value>,
): number {
return a.name.localeCompare(b.name);
}
function organizeItems<Value>(
items: Array<Item<Value>>,
selectableOptionsSeparator?: Separator,
): Array<Item<Value> | Separator> {
const orderedItems = [];
const checkedItems = items.filter(
(item) => !Separator.isSeparator(item) && item.checked,
) as NormalizedChoice<Value>[];
if (checkedItems.length !== 0) {
orderedItems.push(new Separator('--Selected Options--'));
orderedItems.push(...checkedItems.sort(sortNormalizedItems));
}
orderedItems.push(
selectableOptionsSeparator ?? new Separator('--Available Options--'),
);
const nonCheckedItems = items.filter(
(item) => !Separator.isSeparator(item) && !item.checked,
) as NormalizedChoice<Value>[];
orderedItems.push(...nonCheckedItems.sort(sortNormalizedItems));
if (orderedItems.length === 1) {
return [];
}
return orderedItems;
}
interface BuildViewOptions<Value> {
theme: Readonly<Theme<SearchableCheckboxTheme>>;
pageSize: number;
firstRender: { current: boolean };
page: string;
currentOptions: ReadonlyArray<Item<Value>>;
prefix: string;
message: string;
errorMsg?: string;
status: Status;
searchTerm: string;
description?: string;
instructions?: string;
}
interface GetErrorMessageOptions
extends Pick<
BuildViewOptions<any>,
'theme' | 'errorMsg' | 'status' | 'searchTerm'
> {
currentItemCount: number;
}
function getErrorMessage({
theme,
errorMsg,
currentItemCount,
status,
searchTerm,
}: GetErrorMessageOptions): string {
if (errorMsg) {
return `${theme.style.error(errorMsg)}`;
} else if (currentItemCount === 0 && searchTerm !== '' && status === 'idle') {
return theme.style.error('No results found');
}
return '';
}
interface GetHelpTipsOptions
extends Pick<
BuildViewOptions<any>,
'theme' | 'pageSize' | 'firstRender' | 'instructions'
> {
currentItemCount: number;
}
function getHelpTips({
theme,
instructions,
currentItemCount,
pageSize,
firstRender,
}: GetHelpTipsOptions): { helpTipTop: string; helpTipBottom: string } {
let helpTipTop = '';
let helpTipBottom = '';
const defaultTopHelpTip =
instructions ??
`(Press ${theme.style.key('tab')} to select, and ${theme.style.key(
'enter',
)} to proceed`;
const defaultBottomHelpTip = `\n${theme.style.help(
'(Use arrow keys to reveal more choices)',
)}`;
if (theme.helpMode === 'always') {
helpTipTop = theme.style.helpTip(defaultTopHelpTip);
helpTipBottom = currentItemCount > pageSize ? defaultBottomHelpTip : '';
firstRender.current = false;
} else if (theme.helpMode === 'auto' && firstRender.current) {
helpTipTop = theme.style.helpTip(defaultTopHelpTip);
helpTipBottom = currentItemCount > pageSize ? defaultBottomHelpTip : '';
firstRender.current = false;
}
return { helpTipBottom, helpTipTop };
}
function formatRenderedItem<Value>(
item: Readonly<Item<Value>>,
isActive: boolean,
theme: Readonly<Theme<SearchableCheckboxTheme>>,
): string {
if (Separator.isSeparator(item)) {
return ` ${item.separator}`;
}
if (item.disabled) {
const disabledLabel =
typeof item.disabled === 'string' ? item.disabled : '(disabled)';
return theme.style.disabledChoice(`${item.name} ${disabledLabel}`);
}
const checkbox = item.checked ? theme.icon.checked : theme.icon.unchecked;
const color = isActive ? theme.style.highlight : (x: string) => x;
const cursor = isActive ? theme.icon.cursor : ' ';
return color(`${cursor}${checkbox} ${item.name}`);
}
function getListBounds<Value>(items: ReadonlyArray<Item<Value>>): {
first: number;
last: number;
} {
const first = items.findIndex(isSelectable);
// findLastIndex replacement as the project must support older ES versions
let last = -1;
for (let i = items.length; i >= 0; --i) {
if (items[i] && isSelectable(items[i])) {
last = i;
break;
}
}
return { first, last };
}
function buildView<Value>({
page,
prefix,
theme,
status,
message,
errorMsg,
pageSize,
firstRender,
searchTerm,
description,
instructions,
currentOptions,
}: BuildViewOptions<Value>): string {
message = theme.style.message(message);
if (status === 'done') {
const selection = currentOptions.filter(isChecked);
const answer = theme.style.answer(
theme.style.renderSelectedChoices(selection, currentOptions),
);
return `${prefix} ${message} ${answer}`;
}
const currentItemCount = currentOptions.length;
const { helpTipBottom, helpTipTop } = getHelpTips({
theme,
instructions,
currentItemCount,
pageSize,
firstRender,
});
const choiceDescription = description
? `\n${theme.style.description(description)}`
: ``;
const error = getErrorMessage({
theme,
errorMsg,
currentItemCount,
status,
searchTerm,
});
return `${prefix} ${message}${helpTipTop} ${searchTerm}\n${page}${helpTipBottom}${choiceDescription}${error}${ansiEscapes.cursorHide}`;
}
// the isUpKey function from the inquirer package is not used
// because it detects k and p as custom keybindings that cause
// the option selection to go up instead of writing the letters
// in the search string
function isUpKey(key: KeypressEvent): boolean {
return key.name === 'up';
}
// the isDownKey function from the inquirer package is not used
// because it detects j and n as custom keybindings that cause
// the option selection to go down instead of writing the letters
// in the search string
function isDownKey(key: KeypressEvent): boolean {
return key.name === 'down';
}
export const searchableCheckBox = createPrompt(
<Value>(
config: SearchableCheckboxConfig<Value>,
done: (value: Array<Value>) => void,
) => {
const {
instructions,
pageSize = 7,
loop = true,
required,
validate = () => true,
selectableOptionsSeparator,
} = config;
const theme = makeTheme<SearchableCheckboxTheme>(
checkboxTheme,
config.theme,
);
const firstRender = useRef(true);
const [status, setStatus] = useState<Status>('idle');
const prefix = usePrefix({ theme });
const [searchTerm, setSearchTerm] = useState<string>('');
const [errorMsg, setError] = useState<string>();
const normalizedChoices = normalizeChoices(config.choices);
const [optionState, setOptionState] = useState<
SearchableCheckboxState<Value>
>({
options: normalizedChoices,
currentOptionState: Object.fromEntries(
normalizedChoices.map((item) => [item.name, item]),
),
});
const bounds = useMemo(
() => getListBounds(optionState.options),
[optionState.options],
);
const [active, setActive] = useState(bounds.first);
useEffect(() => {
let filteredItems;
if (!searchTerm) {
filteredItems = Object.values(optionState.currentOptionState);
} else {
filteredItems = Object.values(optionState.currentOptionState).filter(
(item) =>
Separator.isSeparator(item) ||
item.name.includes(searchTerm) ||
item.checked,
);
}
setActive(0);
setError(undefined);
setOptionState({
currentOptionState: optionState.currentOptionState,
options: organizeItems(filteredItems, selectableOptionsSeparator),
});
}, [searchTerm]);
useKeypress(async (key, rl) => {
if (isEnterKey(key)) {
const selection = optionState.options.filter(isChecked);
const isValid = await validate(selection);
if (required && !optionState.options.some(isChecked)) {
setError('At least one choice must be selected');
} else if (isValid === true) {
setStatus('done');
done(selection.map((choice) => choice.value));
} else {
setError(isValid || 'You must select a valid value');
setSearchTerm('');
}
} else if (isUpKey(key) || isDownKey(key)) {
if (
loop ||
(isUpKey(key) && active !== bounds.first) ||
(isDownKey(key) && active !== bounds.last)
) {
const offset = isUpKey(key) ? -1 : 1;
let next = active;
do {
next =
(next + offset + optionState.options.length) %
optionState.options.length;
} while (
optionState.options[next] &&
!isSelectable(optionState.options[next])
);
setActive(next);
}
} else if (key.name === 'tab' && optionState.options.length > 0) {
// Avoid the message header to be printed again in the console
rl.clearLine(0);
const currentElement = optionState.options[active];
if (
currentElement &&
!Separator.isSeparator(currentElement) &&
optionState.currentOptionState[currentElement.name]
) {
const updatedDataMap: Record<string, NormalizedChoice<Value>> = {
...optionState.currentOptionState,
[currentElement.name]: toggle(
optionState.currentOptionState[currentElement.name],
) as NormalizedChoice<Value>,
};
setError(undefined);
setOptionState({
options: organizeItems(
Object.values(updatedDataMap),
selectableOptionsSeparator,
),
currentOptionState: updatedDataMap,
});
setSearchTerm('');
}
} else {
setSearchTerm(rl.line);
}
});
let description;
const page = usePagination({
items: optionState.options,
active,
renderItem({ item, isActive }) {
if (isActive && !Separator.isSeparator(item)) {
description = item.description;
}
return formatRenderedItem(item, isActive, theme);
},
pageSize,
loop,
});
return buildView({
page,
theme,
prefix,
status,
pageSize,
errorMsg,
firstRender,
searchTerm,
description,
instructions,
currentOptions: optionState.options,
message: theme.style.message(config.message),
});
},
);

@ -7833,6 +7833,7 @@ __metadata:
"@types/yargs": "npm:^17.0.24"
"@typescript-eslint/eslint-plugin": "npm:^7.4.0"
"@typescript-eslint/parser": "npm:^7.4.0"
ansi-escapes: "npm:^7.0.0"
asn1.js: "npm:^5.4.1"
bignumber.js: "npm:^9.1.1"
chai: "npm:^4.5.0"
@ -14712,6 +14713,15 @@ __metadata:
languageName: node
linkType: hard
"ansi-escapes@npm:^7.0.0":
version: 7.0.0
resolution: "ansi-escapes@npm:7.0.0"
dependencies:
environment: "npm:^1.0.0"
checksum: 2d0e2345087bd7ae6bf122b9cc05ee35560d40dcc061146edcdc02bc2d7c7c50143cd12a22e69a0b5c0f62b948b7bc9a4539ee888b80f5bd33cdfd82d01a70ab
languageName: node
linkType: hard
"ansi-regex@npm:^2.0.0":
version: 2.1.1
resolution: "ansi-regex@npm:2.1.1"
@ -18093,6 +18103,13 @@ __metadata:
languageName: node
linkType: hard
"environment@npm:^1.0.0":
version: 1.1.0
resolution: "environment@npm:1.1.0"
checksum: dd3c1b9825e7f71f1e72b03c2344799ac73f2e9ef81b78ea8b373e55db021786c6b9f3858ea43a436a2c4611052670ec0afe85bc029c384cc71165feee2f4ba6
languageName: node
linkType: hard
"erc721a@npm:^4.2.3":
version: 4.2.3
resolution: "erc721a@npm:4.2.3"

Loading…
Cancel
Save