AccountsCommandBase.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import fs from 'fs'
  2. import path from 'path'
  3. import slug from 'slug'
  4. import inquirer from 'inquirer'
  5. import ExitCodes from '../ExitCodes'
  6. import { CLIError } from '@oclif/errors'
  7. import ApiCommandBase from './ApiCommandBase'
  8. import { Keyring } from '@polkadot/api'
  9. import { formatBalance } from '@polkadot/util'
  10. import { NamedKeyringPair } from '../Types'
  11. import { DerivedBalances } from '@polkadot/api-derive/types'
  12. import { toFixedLength } from '../helpers/display'
  13. const ACCOUNTS_DIRNAME = 'accounts'
  14. const SPECIAL_ACCOUNT_POSTFIX = '__DEV'
  15. /**
  16. * Abstract base class for account-related commands.
  17. *
  18. * All the accounts available in the CLI are stored in the form of json backup files inside:
  19. * { APP_DATA_PATH }/{ ACCOUNTS_DIRNAME } (ie. ~/.local/share/joystream-cli/accounts on Ubuntu)
  20. * Where: APP_DATA_PATH is provided by StateAwareCommandBase and ACCOUNTS_DIRNAME is a const (see above).
  21. */
  22. export default abstract class AccountsCommandBase extends ApiCommandBase {
  23. getAccountsDirPath(): string {
  24. return path.join(this.getAppDataPath(), ACCOUNTS_DIRNAME)
  25. }
  26. getAccountFilePath(account: NamedKeyringPair, isSpecial = false): string {
  27. return path.join(this.getAccountsDirPath(), this.generateAccountFilename(account, isSpecial))
  28. }
  29. generateAccountFilename(account: NamedKeyringPair, isSpecial = false): string {
  30. return `${slug(account.meta.name, '_')}__${account.address}${isSpecial ? SPECIAL_ACCOUNT_POSTFIX : ''}.json`
  31. }
  32. private initAccountsFs(): void {
  33. if (!fs.existsSync(this.getAccountsDirPath())) {
  34. fs.mkdirSync(this.getAccountsDirPath())
  35. }
  36. }
  37. saveAccount(account: NamedKeyringPair, password: string, isSpecial = false): void {
  38. try {
  39. const destPath = this.getAccountFilePath(account, isSpecial)
  40. fs.writeFileSync(destPath, JSON.stringify(account.toJson(password)))
  41. } catch (e) {
  42. throw this.createDataWriteError()
  43. }
  44. }
  45. // Add dev "Alice" and "Bob" accounts
  46. initSpecialAccounts() {
  47. const keyring = new Keyring({ type: 'sr25519' })
  48. keyring.addFromUri('//Alice', { name: 'Alice' })
  49. keyring.addFromUri('//Bob', { name: 'Bob' })
  50. keyring.getPairs().forEach((pair) => this.saveAccount({ ...pair, meta: { name: pair.meta.name } }, '', true))
  51. }
  52. fetchAccountFromJsonFile(jsonBackupFilePath: string): NamedKeyringPair {
  53. if (!fs.existsSync(jsonBackupFilePath)) {
  54. throw new CLIError('Input file does not exist!', { exit: ExitCodes.FileNotFound })
  55. }
  56. if (path.extname(jsonBackupFilePath) !== '.json') {
  57. throw new CLIError('Invalid input file: File extension should be .json', { exit: ExitCodes.InvalidFile })
  58. }
  59. let accountJsonObj: any
  60. try {
  61. accountJsonObj = require(jsonBackupFilePath)
  62. } catch (e) {
  63. throw new CLIError('Provided backup file is not valid or cannot be accessed', { exit: ExitCodes.InvalidFile })
  64. }
  65. if (typeof accountJsonObj !== 'object' || accountJsonObj === null) {
  66. throw new CLIError('Provided backup file is not valid', { exit: ExitCodes.InvalidFile })
  67. }
  68. // Force some default account name if none is provided in the original backup
  69. if (!accountJsonObj.meta) accountJsonObj.meta = {}
  70. if (!accountJsonObj.meta.name) accountJsonObj.meta.name = 'Unnamed Account'
  71. const keyring = new Keyring()
  72. let account: NamedKeyringPair
  73. try {
  74. // Try adding and retrieving the keys in order to validate that the backup file is correct
  75. keyring.addFromJson(accountJsonObj)
  76. account = keyring.getPair(accountJsonObj.address) as NamedKeyringPair // We can be sure it's named, because we forced it before
  77. } catch (e) {
  78. throw new CLIError('Provided backup file is not valid', { exit: ExitCodes.InvalidFile })
  79. }
  80. return account
  81. }
  82. private fetchAccountOrNullFromFile(jsonFilePath: string): NamedKeyringPair | null {
  83. try {
  84. return this.fetchAccountFromJsonFile(jsonFilePath)
  85. } catch (e) {
  86. // Here in case of a typical CLIError we just return null (otherwise we throw)
  87. if (!(e instanceof CLIError)) throw e
  88. return null
  89. }
  90. }
  91. fetchAccounts(includeSpecial = false): NamedKeyringPair[] {
  92. let files: string[] = []
  93. const accountDir = this.getAccountsDirPath()
  94. try {
  95. files = fs.readdirSync(accountDir)
  96. } catch (e) {
  97. // Do nothing
  98. }
  99. // We have to assert the type, because TS is not aware that we're filtering out the nulls at the end
  100. return files
  101. .map((fileName) => {
  102. const filePath = path.join(accountDir, fileName)
  103. if (!includeSpecial && filePath.includes(SPECIAL_ACCOUNT_POSTFIX + '.')) return null
  104. return this.fetchAccountOrNullFromFile(filePath)
  105. })
  106. .filter((accObj) => accObj !== null) as NamedKeyringPair[]
  107. }
  108. getSelectedAccountFilename(): string {
  109. return this.getPreservedState().selectedAccountFilename
  110. }
  111. getSelectedAccount(): NamedKeyringPair | null {
  112. const selectedAccountFilename = this.getSelectedAccountFilename()
  113. if (!selectedAccountFilename) {
  114. return null
  115. }
  116. const account = this.fetchAccountOrNullFromFile(path.join(this.getAccountsDirPath(), selectedAccountFilename))
  117. return account
  118. }
  119. // Use when account usage is required in given command
  120. async getRequiredSelectedAccount(promptIfMissing = true): Promise<NamedKeyringPair> {
  121. let selectedAccount: NamedKeyringPair | null = this.getSelectedAccount()
  122. if (!selectedAccount) {
  123. this.warn('No default account selected! Use account:choose to set the default account!')
  124. if (!promptIfMissing) this.exit(ExitCodes.NoAccountSelected)
  125. const accounts: NamedKeyringPair[] = this.fetchAccounts()
  126. if (!accounts.length) {
  127. this.error('There are no accounts available!', { exit: ExitCodes.NoAccountFound })
  128. }
  129. selectedAccount = await this.promptForAccount(accounts)
  130. }
  131. return selectedAccount
  132. }
  133. async setSelectedAccount(account: NamedKeyringPair): Promise<void> {
  134. const accountFilename = fs.existsSync(this.getAccountFilePath(account, true))
  135. ? this.generateAccountFilename(account, true)
  136. : this.generateAccountFilename(account)
  137. await this.setPreservedState({ selectedAccountFilename: accountFilename })
  138. }
  139. async promptForPassword(message = "Your account's password") {
  140. const { password } = await inquirer.prompt([{ name: 'password', type: 'password', message }])
  141. return password
  142. }
  143. async requireConfirmation(message = 'Are you sure you want to execute this action?'): Promise<void> {
  144. const { confirmed } = await inquirer.prompt([{ type: 'confirm', name: 'confirmed', message, default: false }])
  145. if (!confirmed) this.exit(ExitCodes.OK)
  146. }
  147. async promptForAccount(
  148. accounts: NamedKeyringPair[],
  149. defaultAccount: NamedKeyringPair | null = null,
  150. message = 'Select an account',
  151. showBalances = true
  152. ): Promise<NamedKeyringPair> {
  153. let balances: DerivedBalances[]
  154. if (showBalances) {
  155. balances = await this.getApi().getAccountsBalancesInfo(accounts.map((acc) => acc.address))
  156. }
  157. const longestAccNameLength: number = accounts.reduce((prev, curr) => Math.max(curr.meta.name.length, prev), 0)
  158. const accNameColLength: number = Math.min(longestAccNameLength + 1, 20)
  159. const { chosenAccountFilename } = await inquirer.prompt([
  160. {
  161. name: 'chosenAccountFilename',
  162. message,
  163. type: 'list',
  164. choices: accounts.map((account: NamedKeyringPair, i) => ({
  165. name:
  166. `${toFixedLength(account.meta.name, accNameColLength)} | ` +
  167. `${account.address} | ` +
  168. ((showBalances || '') &&
  169. `${formatBalance(balances[i].availableBalance)} / ` + `${formatBalance(balances[i].votingBalance)}`),
  170. value: this.generateAccountFilename(account),
  171. short: `${account.meta.name} (${account.address})`,
  172. })),
  173. default: defaultAccount && this.generateAccountFilename(defaultAccount),
  174. },
  175. ])
  176. return accounts.find((acc) => this.generateAccountFilename(acc) === chosenAccountFilename) as NamedKeyringPair
  177. }
  178. async requestAccountDecoding(account: NamedKeyringPair): Promise<void> {
  179. const password: string = await this.promptForPassword()
  180. try {
  181. account.decodePkcs8(password)
  182. } catch (e) {
  183. this.error('Invalid password!', { exit: ExitCodes.InvalidInput })
  184. }
  185. }
  186. async init() {
  187. await super.init()
  188. try {
  189. this.initAccountsFs()
  190. this.initSpecialAccounts()
  191. } catch (e) {
  192. throw this.createDataDirInitError()
  193. }
  194. }
  195. }