StateAwareCommandBase.ts 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131
  1. import fs from 'fs'
  2. import path from 'path'
  3. import ExitCodes from '../ExitCodes'
  4. import { CLIError } from '@oclif/errors'
  5. import lockFile from 'proper-lockfile'
  6. import DefaultCommandBase from './DefaultCommandBase'
  7. import os from 'os'
  8. import _ from 'lodash'
  9. // Type for the state object (which is preserved as json in the state file)
  10. type StateObject = {
  11. selectedAccountFilename: string
  12. apiUri: string
  13. }
  14. // State object default values
  15. const DEFAULT_STATE: StateObject = {
  16. selectedAccountFilename: '',
  17. apiUri: '',
  18. }
  19. // State file path (relative to getAppDataPath())
  20. const STATE_FILE = '/state.json'
  21. // Possible data directory access errors
  22. enum DataDirErrorType {
  23. Init = 0,
  24. Read = 1,
  25. Write = 2,
  26. }
  27. /**
  28. * Abstract base class for commands that need to work with the preserved state.
  29. *
  30. * The preserved state is kept in a json file inside the data directory.
  31. * The state object contains all the information that needs to be preserved across sessions, ie. the default account
  32. * choosen by the user after executing account:choose command etc. (see "StateObject" type above).
  33. */
  34. export default abstract class StateAwareCommandBase extends DefaultCommandBase {
  35. getAppDataPath(): string {
  36. const systemAppDataPath =
  37. process.env.APPDATA ||
  38. (process.platform === 'darwin'
  39. ? path.join(os.homedir(), '/Library/Application Support')
  40. : path.join(os.homedir(), '/.local/share'))
  41. // eslint-disable-next-line @typescript-eslint/no-var-requires
  42. const packageJson: { name?: string } = require('../../package.json')
  43. if (!packageJson || !packageJson.name) {
  44. throw new CLIError('Cannot get package name from package.json!')
  45. }
  46. return path.join(systemAppDataPath, _.kebabCase(packageJson.name))
  47. }
  48. getStateFilePath(): string {
  49. return path.join(this.getAppDataPath(), STATE_FILE)
  50. }
  51. private createDataDirFsError(errorType: DataDirErrorType, specificPath = '') {
  52. const actionStrs: { [x in DataDirErrorType]: string } = {
  53. [DataDirErrorType.Init]: 'initialize',
  54. [DataDirErrorType.Read]: 'read from',
  55. [DataDirErrorType.Write]: 'write into',
  56. }
  57. const errorMsg =
  58. `Unexpected error while trying to ${actionStrs[errorType]} the data directory.` +
  59. `(${path.join(this.getAppDataPath(), specificPath)})! Permissions issue?`
  60. return new CLIError(errorMsg, { exit: ExitCodes.FsOperationFailed })
  61. }
  62. createDataReadError(specificPath = ''): CLIError {
  63. return this.createDataDirFsError(DataDirErrorType.Read, specificPath)
  64. }
  65. createDataWriteError(specificPath = ''): CLIError {
  66. return this.createDataDirFsError(DataDirErrorType.Write, specificPath)
  67. }
  68. createDataDirInitError(specificPath = ''): CLIError {
  69. return this.createDataDirFsError(DataDirErrorType.Init, specificPath)
  70. }
  71. private initStateFs(): void {
  72. if (!fs.existsSync(this.getAppDataPath())) {
  73. fs.mkdirSync(this.getAppDataPath())
  74. }
  75. if (!fs.existsSync(this.getStateFilePath())) {
  76. fs.writeFileSync(this.getStateFilePath(), JSON.stringify(DEFAULT_STATE))
  77. }
  78. }
  79. getPreservedState(): StateObject {
  80. let preservedState: StateObject
  81. try {
  82. // Use readFileSync instead of "require" in order to always get a "fresh" state
  83. preservedState = JSON.parse(fs.readFileSync(this.getStateFilePath()).toString()) as StateObject
  84. } catch (e) {
  85. throw this.createDataReadError()
  86. }
  87. // The state preserved in a file may be missing some required values ie.
  88. // if the user previously used the older version of the software.
  89. // That's why we combine it with default state before returing.
  90. return { ...DEFAULT_STATE, ...preservedState }
  91. }
  92. // Modifies preserved state. Uses file lock in order to avoid updating an older state.
  93. // (which could potentialy change between read and write operation)
  94. async setPreservedState(modifiedState: Partial<StateObject>): Promise<void> {
  95. const stateFilePath = this.getStateFilePath()
  96. const unlock = await lockFile.lock(stateFilePath)
  97. const oldState: StateObject = this.getPreservedState()
  98. const newState: StateObject = { ...oldState, ...modifiedState }
  99. try {
  100. fs.writeFileSync(stateFilePath, JSON.stringify(newState))
  101. } catch (e) {
  102. await unlock()
  103. throw this.createDataWriteError()
  104. }
  105. await unlock()
  106. }
  107. async init() {
  108. await super.init()
  109. try {
  110. await this.initStateFs()
  111. } catch (e) {
  112. throw this.createDataDirInitError()
  113. }
  114. }
  115. }