1import JsonFile, { JSONObject } from '@expo/json-file'; 2import chalk from 'chalk'; 3import fs from 'fs-extra'; 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{ 97 // Descriptor properties 98 readonly tasks: Task<Args>[]; 99 100 readonly backupFilePath: string | null = null; 101 102 readonly backupExpirationTime: number = 60 * 60 * 1000; 103 104 readonly validateBackup: (backup: TasksRunnerBackup<BackupDataType>) => Promiseable<boolean> = 105 () => true; 106 107 readonly shouldUseBackup: (backup: TasksRunnerBackup<BackupDataType>) => Promiseable<boolean> = 108 () => true; 109 110 readonly restoreBackup: ( 111 backup: TasksRunnerBackup<BackupDataType>, 112 ...args: Args 113 ) => Promiseable<void> = () => {}; 114 115 readonly createBackupData: (task, ...args: Args) => BackupDataType | null = () => null; 116 117 readonly backupValidationFailed?: (backup) => void; 118 119 readonly taskSucceeded?: (task: Task<Args>) => any; 120 121 readonly taskFailed?: (task: Task<Args>, error: Error) => any; 122 123 readonly resolvedTasks: Task<Args>[]; 124 125 constructor(descriptor: TaskRunnerDescriptor<Args, BackupDataType>) { 126 const { tasks, ...rest } = descriptor; 127 128 this.tasks = ([] as Task<Args>[]).concat(tasks); 129 this.resolvedTasks = resolveTasksList(this.tasks); 130 131 Object.assign(this, rest); 132 } 133 134 /** 135 * Resolves to a boolean value determining whether the backup file exists. 136 */ 137 async backupExistsAsync(): Promise<boolean> { 138 if (!this.backupFilePath) { 139 return false; 140 } 141 try { 142 await fs.access(this.backupFilePath, fs.constants.R_OK); 143 return true; 144 } catch { 145 return false; 146 } 147 } 148 149 /** 150 * Returns action's backup if it exists and is still valid, `null` otherwise. 151 */ 152 async getBackupAsync(): Promise<TasksRunnerBackup<BackupDataType> | null> { 153 if (!this.backupFilePath || !(await this.backupExistsAsync())) { 154 return null; 155 } 156 const backup = await JsonFile.readAsync<TasksRunnerBackup<BackupDataType>>(this.backupFilePath); 157 158 if (!(await this.isBackupValid(backup))) { 159 await this.backupValidationFailed?.(backup); 160 return null; 161 } 162 return !this.shouldUseBackup || (await this.shouldUseBackup(backup)) ? backup : null; 163 } 164 165 /** 166 * Validates backup compatibility with options passed to the command. 167 */ 168 async isBackupValid(backup: TasksRunnerBackup<BackupDataType>): Promise<boolean> { 169 const tasksComparator = (a, b) => a === b.name; 170 171 if ( 172 Date.now() - backup.timestamp < this.backupExpirationTime && 173 arraysCompare(backup.resolvedTasks, this.resolvedTasks, tasksComparator) && 174 arraysCompare(backup.tasks, this.tasks, tasksComparator) 175 ) { 176 return (await this.validateBackup?.(backup)) ?? true; 177 } 178 return false; 179 } 180 181 /** 182 * Saves backup of tasks state. 183 */ 184 async saveBackup(task: Task<Args>, ...args: Args) { 185 if (!this.backupFilePath) { 186 return; 187 } 188 189 const data = await this.createBackupData(task, ...args); 190 const backup: TasksRunnerBackup<BackupDataType> = { 191 timestamp: Date.now(), 192 tasks: this.tasks.map((task) => task.name), 193 resolvedTasks: this.resolvedTasks.map((task) => task.name), 194 lastTask: task.name, 195 data, 196 }; 197 await fs.outputFile(this.backupFilePath, JSON.stringify(backup, null, 2)); 198 } 199 200 /** 201 * Removes backup file if specified. Must be synchronous. 202 */ 203 invalidateBackup() { 204 if (this.backupFilePath) { 205 fs.removeSync(this.backupFilePath); 206 } 207 } 208 209 /** 210 * Restores backup if possible and executes tasks until they stop, throw or finish. Re-throws task errors. 211 */ 212 async runAsync(...args: Args): Promise<Args> { 213 const backup = await this.getBackupAsync(); 214 const startingIndex = backup 215 ? this.resolvedTasks.findIndex((task) => task.name === backup.lastTask) + 1 216 : 0; 217 218 if (backup) { 219 await this.restoreBackup(backup, ...args); 220 } 221 222 // Filter tasks to run: required ones and all those after last backup. 223 const tasks = this.resolvedTasks.filter((task, taskIndex) => { 224 return task.required || taskIndex >= startingIndex; 225 }); 226 227 let nextArgs: Args = args; 228 229 for (const task of tasks) { 230 try { 231 const result = await task.taskFunction?.(...nextArgs); 232 233 // The task has stopped further tasks execution. 234 if (result === Task.STOP) { 235 break; 236 } 237 if (Array.isArray(result)) { 238 nextArgs = result; 239 } 240 241 // Stage declared files in local repository. This is also a part of the backup. 242 await Git.addFilesAsync(task.filesToStage); 243 } catch (error) { 244 // Discard unstaged changes in declared files. 245 await Git.discardFilesAsync(task.filesToStage); 246 247 this.taskFailed?.(task, error); 248 throw new TaskError<Task<Args>>(task, error); 249 } 250 251 this.taskSucceeded?.(task); 252 253 if (task.backupable) { 254 // Make a backup after each successful backupable task. 255 await this.saveBackup(task, ...args); 256 } 257 } 258 259 // If we reach here - we're done and backup should be invalidated. 260 this.invalidateBackup(); 261 262 return nextArgs; 263 } 264 265 /** 266 * Same as `runAsync` but handles caught errors and calls `process.exit`. 267 */ 268 async runAndExitAsync(...args: Args): Promise<void> { 269 try { 270 await this.runAsync(...args); 271 process.exit(0); 272 } catch (error) { 273 logger.error(); 274 275 if (error instanceof TaskError) { 276 logger.error(` Execution failed for task ${chalk.cyan(error.task.name)}.`); 277 } 278 279 logger.error(' Error:', error.message); 280 281 if (error.stack) { 282 const stack = error.stack.split(`${error.message}\n`); 283 logger.debug(stack[1]); 284 } 285 286 error.stderr && logger.error(' stderr output:\n', chalk.reset(error.stderr)); 287 process.exit(1); 288 } 289 } 290} 291 292export class Task<Args extends any[] = []> implements TaskDescriptor<Args> { 293 static STOP: symbol = Symbol(); 294 295 readonly name: string; 296 readonly dependsOn: Task<Args>[] = []; 297 readonly filesToStage: string[] = []; 298 readonly required: boolean = false; 299 readonly backupable: boolean = true; 300 readonly taskFunction?: TaskFunction<Args>; 301 302 constructor(descriptor: TaskDescriptor<Args> | string, taskFunction?: TaskFunction<Args>) { 303 if (typeof descriptor === 'string') { 304 this.name = descriptor; 305 } else { 306 const { name, dependsOn, filesToStage, required, backupable } = descriptor; 307 this.name = name; 308 this.dependsOn = dependsOn ? ([] as Task<Args>[]).concat(dependsOn) : []; 309 this.filesToStage = filesToStage ? ([] as string[]).concat(filesToStage) : []; 310 this.required = required ?? this.required; 311 this.backupable = backupable ?? this.backupable; 312 } 313 this.taskFunction = taskFunction; 314 } 315} 316 317function resolveTasksList<Args extends any[]>(tasks: Task<Args>[]): Task<Args>[] { 318 const list = new Set<Task<Args>>(); 319 320 function iterateThroughDependencies(task: Task<Args>) { 321 for (const dependency of task.dependsOn) { 322 iterateThroughDependencies(dependency); 323 } 324 list.add(task); 325 } 326 327 tasks.forEach((task) => iterateThroughDependencies(task)); 328 329 return [...list]; 330} 331 332function arraysCompare(arr1, arr2, comparator = (a, b) => a === b): boolean { 333 return arr1.length === arr2.length && arr1.every((item, index) => comparator(item, arr2[index])); 334} 335