1import fs from 'fs-extra'; 2import chalk from 'chalk'; 3import JsonFile, { JSONObject } from '@expo/json-file'; 4 5import Git from './Git'; 6import logger from './Logger'; 7 8/** 9 * Descriptor of single task. Defines class members and the main function. 10 */ 11export type TaskDescriptor<Args extends any[]> = { 12 /** 13 * Name of the task. 14 */ 15 name: string; 16 17 /** 18 * A list of other tasks this task depends on. All these tasks will be executed before this one. 19 */ 20 dependsOn?: Task<Args>[] | Task<Args>; 21 22 /** 23 * File paths to stage in the repository. 24 */ 25 filesToStage?: string[] | string; 26 27 /** 28 * Task is required and thus will be run even if restored from the backup. 29 */ 30 required?: boolean; 31 32 /** 33 * Whether it makes sense to save a backup after this task is completed. 34 */ 35 backupable?: boolean; 36}; 37 38/** 39 * Handy return type for methods that might be asynchronous. 40 */ 41type Promiseable<T> = T | Promise<T>; 42 43/** 44 * An object that is being passed to TaskRunner constructor and provides some customization. 45 */ 46export type TaskRunnerDescriptor<Args extends any[], BackupDataType = null> = { 47 tasks: Task<Args>[] | Task<Args>; 48 backupFilePath?: string | null; 49 backupExpirationTime?: number; 50 validateBackup?: (backup) => Promiseable<boolean>; 51 shouldUseBackup?: (backup) => Promiseable<boolean>; 52 restoreBackup?: (backup, ...args: Args) => Promiseable<void>; 53 createBackupData?: (task, ...args: Args) => Promiseable<BackupDataType | null>; 54 backupValidationFailed?: (backup) => void; 55 taskSucceeded?: (task: Task<Args>) => void; 56 taskFailed?: (task: Task<Args>, error: any) => void; 57}; 58 59/** 60 * An object that is being stored in the backup file. 61 */ 62export type TasksRunnerBackup<DataType extends JSONObject | null = null> = { 63 tasks: string[]; 64 resolvedTasks: string[]; 65 lastTask: string; 66 timestamp: number; 67 data: DataType | null; 68}; 69 70/** 71 * Signature of the function is being executed as part of the task. 72 */ 73export type TaskFunction<Args extends any[]> = (...args: Args) => Promise<void | symbol | Args>; 74 75/** 76 * Class of error that might be thrown when running tasks. 77 */ 78export class TaskError<TaskType extends { name: string }> extends Error { 79 readonly stderr?: string; 80 readonly stack?: string; 81 82 constructor(readonly task: TaskType, error: Error) { 83 super(error.message); 84 this.stderr = (error as any).stderr; 85 this.stack = error.stack; 86 } 87} 88 89/** 90 * Task runner, as its name suggests, runs given task. One task can depend on other tasks 91 * and the runner makes sure they all are being run. Runner also provides an easy way to 92 * backup and restore tasks' state. 93 */ 94export class TaskRunner<Args extends any[], BackupDataType extends JSONObject | null = null> 95 implements TaskRunnerDescriptor<Args, BackupDataType> { 96 // Descriptor properties 97 readonly tasks: Task<Args>[]; 98 99 readonly backupFilePath: string | null = null; 100 101 readonly backupExpirationTime: number = 60 * 60 * 1000; 102 103 readonly validateBackup: ( 104 backup: TasksRunnerBackup<BackupDataType> 105 ) => Promiseable<boolean> = () => true; 106 107 readonly shouldUseBackup: ( 108 backup: TasksRunnerBackup<BackupDataType> 109 ) => Promiseable<boolean> = () => true; 110 111 readonly restoreBackup: ( 112 backup: TasksRunnerBackup<BackupDataType>, 113 ...args: Args 114 ) => Promiseable<void> = () => {}; 115 116 readonly createBackupData: (task, ...args: Args) => BackupDataType | null = () => null; 117 118 readonly backupValidationFailed?: (backup) => void; 119 120 readonly taskSucceeded?: (task: Task<Args>) => any; 121 122 readonly taskFailed?: (task: Task<Args>, error: Error) => any; 123 124 readonly resolvedTasks: Task<Args>[]; 125 126 constructor(descriptor: TaskRunnerDescriptor<Args, BackupDataType>) { 127 const { tasks, ...rest } = descriptor; 128 129 this.tasks = ([] as Task<Args>[]).concat(tasks); 130 this.resolvedTasks = resolveTasksList(this.tasks); 131 132 Object.assign(this, rest); 133 } 134 135 /** 136 * Resolves to a boolean value determining whether the backup file exists. 137 */ 138 async backupExistsAsync(): Promise<boolean> { 139 if (!this.backupFilePath) { 140 return false; 141 } 142 try { 143 await fs.access(this.backupFilePath, fs.constants.R_OK); 144 return true; 145 } catch (error) { 146 return false; 147 } 148 } 149 150 /** 151 * Returns action's backup if it exists and is still valid, `null` otherwise. 152 */ 153 async getBackupAsync(): Promise<TasksRunnerBackup<BackupDataType> | null> { 154 if (!this.backupFilePath || !(await this.backupExistsAsync())) { 155 return null; 156 } 157 const backup = await JsonFile.readAsync<TasksRunnerBackup<BackupDataType>>(this.backupFilePath); 158 159 if (!(await this.isBackupValid(backup))) { 160 await this.backupValidationFailed?.(backup); 161 return null; 162 } 163 return !this.shouldUseBackup || (await this.shouldUseBackup(backup)) ? backup : null; 164 } 165 166 /** 167 * Validates backup compatibility with options passed to the command. 168 */ 169 async isBackupValid(backup: TasksRunnerBackup<BackupDataType>): Promise<boolean> { 170 const tasksComparator = (a, b) => a === b.name; 171 172 if ( 173 Date.now() - backup.timestamp < this.backupExpirationTime && 174 arraysCompare(backup.resolvedTasks, this.resolvedTasks, tasksComparator) && 175 arraysCompare(backup.tasks, this.tasks, tasksComparator) 176 ) { 177 return (await this.validateBackup?.(backup)) ?? true; 178 } 179 return false; 180 } 181 182 /** 183 * Saves backup of tasks state. 184 */ 185 async saveBackup(task: Task<Args>, ...args: Args) { 186 if (!this.backupFilePath) { 187 return; 188 } 189 190 const data = await this.createBackupData(task, ...args); 191 const backup: TasksRunnerBackup<BackupDataType> = { 192 timestamp: Date.now(), 193 tasks: this.tasks.map((task) => task.name), 194 resolvedTasks: this.resolvedTasks.map((task) => task.name), 195 lastTask: task.name, 196 data, 197 }; 198 await fs.outputFile(this.backupFilePath, JSON.stringify(backup, null, 2)); 199 } 200 201 /** 202 * Removes backup file if specified. Must be synchronous. 203 */ 204 invalidateBackup() { 205 if (this.backupFilePath) { 206 fs.removeSync(this.backupFilePath); 207 } 208 } 209 210 /** 211 * Restores backup if possible and executes tasks until they stop, throw or finish. Re-throws task errors. 212 */ 213 async runAsync(...args: Args): Promise<Args> { 214 const backup = await this.getBackupAsync(); 215 const startingIndex = backup 216 ? this.resolvedTasks.findIndex((task) => task.name === backup.lastTask) + 1 217 : 0; 218 219 if (backup) { 220 await this.restoreBackup(backup, ...args); 221 } 222 223 // Filter tasks to run: required ones and all those after last backup. 224 const tasks = this.resolvedTasks.filter((task, taskIndex) => { 225 return task.required || taskIndex >= startingIndex; 226 }); 227 228 let nextArgs: Args = args; 229 230 for (const task of tasks) { 231 try { 232 const result = await task.taskFunction?.(...nextArgs); 233 234 // The task has stopped further tasks execution. 235 if (result === Task.STOP) { 236 break; 237 } 238 if (Array.isArray(result)) { 239 nextArgs = result; 240 } 241 242 // Stage declared files in local repository. This is also a part of the backup. 243 await Git.addFilesAsync(task.filesToStage); 244 } catch (error) { 245 // Discard unstaged changes in declared files. 246 await Git.discardFilesAsync(task.filesToStage); 247 248 this.taskFailed?.(task, error); 249 throw new TaskError<Task<Args>>(task, error); 250 } 251 252 this.taskSucceeded?.(task); 253 254 if (task.backupable) { 255 // Make a backup after each successful backupable task. 256 await this.saveBackup(task, ...args); 257 } 258 } 259 260 // If we reach here - we're done and backup should be invalidated. 261 this.invalidateBackup(); 262 263 return nextArgs; 264 } 265 266 /** 267 * Same as `runAsync` but handles caught errors and calls `process.exit`. 268 */ 269 async runAndExitAsync(...args: Args): Promise<void> { 270 try { 271 await this.runAsync(...args); 272 process.exit(0); 273 } catch (error) { 274 logger.error(); 275 276 if (error instanceof TaskError) { 277 logger.error(` Execution failed for task ${chalk.cyan(error.task.name)}.`); 278 } 279 280 logger.error(' Error:', error.message); 281 282 if (error.stack) { 283 const stack = error.stack.split(`${error.message}\n`); 284 logger.debug(stack[1]); 285 } 286 287 error.stderr && logger.error(' stderr output:\n', chalk.reset(error.stderr)); 288 process.exit(1); 289 } 290 } 291} 292 293export class Task<Args extends any[] = []> implements TaskDescriptor<Args> { 294 static STOP: symbol = Symbol(); 295 296 readonly name: string; 297 readonly dependsOn: Task<Args>[] = []; 298 readonly filesToStage: string[] = []; 299 readonly required: boolean = false; 300 readonly backupable: boolean = true; 301 readonly taskFunction?: TaskFunction<Args>; 302 303 constructor(descriptor: TaskDescriptor<Args> | string, taskFunction?: TaskFunction<Args>) { 304 if (typeof descriptor === 'string') { 305 this.name = descriptor; 306 } else { 307 const { name, dependsOn, filesToStage, required, backupable } = descriptor; 308 this.name = name; 309 this.dependsOn = dependsOn ? ([] as Task<Args>[]).concat(dependsOn) : []; 310 this.filesToStage = filesToStage ? ([] as string[]).concat(filesToStage) : []; 311 this.required = required ?? this.required; 312 this.backupable = backupable ?? this.backupable; 313 } 314 this.taskFunction = taskFunction; 315 } 316} 317 318function resolveTasksList<Args extends any[]>(tasks: Task<Args>[]): Task<Args>[] { 319 const list = new Set<Task<Args>>(); 320 321 function iterateThroughDependencies(task: Task<Args>) { 322 for (const dependency of task.dependsOn) { 323 iterateThroughDependencies(dependency); 324 } 325 list.add(task); 326 } 327 328 tasks.forEach((task) => iterateThroughDependencies(task)); 329 330 return [...list]; 331} 332 333function arraysCompare(arr1, arr2, comparator = (a, b) => a === b): boolean { 334 return arr1.length === arr2.length && arr1.every((item, index) => comparator(item, arr2[index])); 335} 336