1// @ts-check 2/// <reference path="../types/ambient.d.ts" /> 3 4const fs = require("fs"); 5const path = require("path"); 6const log = require("fancy-log"); 7const mkdirp = require("mkdirp"); 8const del = require("del"); 9const File = require("vinyl"); 10const ts = require("../../lib/typescript"); 11const chalk = require("chalk"); 12const { spawn } = require("child_process"); 13const { CancellationToken, CancelError, Deferred } = require("prex"); 14const { Readable, Duplex } = require("stream"); 15 16const isWindows = /^win/.test(process.platform); 17 18/** 19 * Executes the provided command once with the supplied arguments. 20 * @param {string} cmd 21 * @param {string[]} args 22 * @param {ExecOptions} [options] 23 * 24 * @typedef ExecOptions 25 * @property {boolean} [ignoreExitCode] 26 * @property {import("prex").CancellationToken} [cancelToken] 27 * @property {boolean} [hidePrompt] 28 * @property {boolean} [waitForExit=true] 29 */ 30function exec(cmd, args, options = {}) { 31 return /**@type {Promise<{exitCode: number}>}*/(new Promise((resolve, reject) => { 32 const { ignoreExitCode, cancelToken = CancellationToken.none, waitForExit = true } = options; 33 cancelToken.throwIfCancellationRequested(); 34 35 // TODO (weswig): Update child_process types to add windowsVerbatimArguments to the type definition 36 const subshellFlag = isWindows ? "/c" : "-c"; 37 const command = isWindows ? [possiblyQuote(cmd), ...args] : [`${cmd} ${args.join(" ")}`]; 38 39 if (!options.hidePrompt) log(`> ${chalk.green(cmd)} ${args.join(" ")}`); 40 const proc = spawn(isWindows ? "cmd" : "/bin/sh", [subshellFlag, ...command], { stdio: waitForExit ? "inherit" : "ignore", windowsVerbatimArguments: true }); 41 const registration = cancelToken.register(() => { 42 log(`${chalk.red("killing")} '${chalk.green(cmd)} ${args.join(" ")}'...`); 43 proc.kill("SIGINT"); 44 proc.kill("SIGTERM"); 45 reject(new CancelError()); 46 }); 47 if (waitForExit) { 48 proc.on("exit", exitCode => { 49 registration.unregister(); 50 if (exitCode === 0 || ignoreExitCode) { 51 resolve({ exitCode }); 52 } 53 else { 54 reject(new Error(`Process exited with code: ${exitCode}`)); 55 } 56 }); 57 proc.on("error", error => { 58 registration.unregister(); 59 reject(error); 60 }); 61 } 62 else { 63 proc.unref(); 64 // wait a short period in order to allow the process to start successfully before Node exits. 65 setTimeout(() => resolve({ exitCode: undefined }), 100); 66 } 67 })); 68} 69exports.exec = exec; 70 71/** 72 * @param {string} cmd 73 */ 74function possiblyQuote(cmd) { 75 return cmd.indexOf(" ") >= 0 ? `"${cmd}"` : cmd; 76} 77 78/** 79 * @param {ts.Diagnostic[]} diagnostics 80 * @param {{ cwd?: string, pretty?: boolean }} [options] 81 */ 82function formatDiagnostics(diagnostics, options) { 83 return options && options.pretty 84 ? ts.formatDiagnosticsWithColorAndContext(diagnostics, getFormatDiagnosticsHost(options && options.cwd)) 85 : ts.formatDiagnostics(diagnostics, getFormatDiagnosticsHost(options && options.cwd)); 86} 87exports.formatDiagnostics = formatDiagnostics; 88 89/** 90 * @param {ts.Diagnostic[]} diagnostics 91 * @param {{ cwd?: string }} [options] 92 */ 93function reportDiagnostics(diagnostics, options) { 94 log(formatDiagnostics(diagnostics, { cwd: options && options.cwd, pretty: process.stdout.isTTY })); 95} 96exports.reportDiagnostics = reportDiagnostics; 97 98/** 99 * @param {string | undefined} cwd 100 * @returns {ts.FormatDiagnosticsHost} 101 */ 102function getFormatDiagnosticsHost(cwd) { 103 return { 104 getCanonicalFileName: fileName => fileName, 105 getCurrentDirectory: () => cwd, 106 getNewLine: () => ts.sys.newLine, 107 }; 108} 109exports.getFormatDiagnosticsHost = getFormatDiagnosticsHost; 110 111/** 112 * Reads JSON data with optional comments using the LKG TypeScript compiler 113 * @param {string} jsonPath 114 */ 115function readJson(jsonPath) { 116 const jsonText = fs.readFileSync(jsonPath, "utf8"); 117 const result = ts.parseConfigFileTextToJson(jsonPath, jsonText); 118 if (result.error) { 119 reportDiagnostics([result.error]); 120 throw new Error("An error occurred during parse."); 121 } 122 return result.config; 123} 124exports.readJson = readJson; 125 126/** 127 * @param {File} file 128 */ 129function streamFromFile(file) { 130 return file.isBuffer() ? streamFromBuffer(file.contents) : 131 file.isStream() ? file.contents : 132 fs.createReadStream(file.path, { autoClose: true }); 133} 134exports.streamFromFile = streamFromFile; 135 136/** 137 * @param {Buffer} buffer 138 */ 139function streamFromBuffer(buffer) { 140 return new Readable({ 141 read() { 142 this.push(buffer); 143 this.push(null); 144 } 145 }); 146} 147exports.streamFromBuffer = streamFromBuffer; 148 149/** 150 * @param {string | string[]} source 151 * @param {string | string[]} dest 152 * @returns {boolean} 153 */ 154function needsUpdate(source, dest) { 155 if (typeof source === "string" && typeof dest === "string") { 156 if (fs.existsSync(dest)) { 157 const {mtime: outTime} = fs.statSync(dest); 158 const {mtime: inTime} = fs.statSync(source); 159 if (+inTime <= +outTime) { 160 return false; 161 } 162 } 163 } 164 else if (typeof source === "string" && typeof dest !== "string") { 165 const {mtime: inTime} = fs.statSync(source); 166 for (const filepath of dest) { 167 if (fs.existsSync(filepath)) { 168 const {mtime: outTime} = fs.statSync(filepath); 169 if (+inTime > +outTime) { 170 return true; 171 } 172 } 173 else { 174 return true; 175 } 176 } 177 return false; 178 } 179 else if (typeof source !== "string" && typeof dest === "string") { 180 if (fs.existsSync(dest)) { 181 const {mtime: outTime} = fs.statSync(dest); 182 for (const filepath of source) { 183 if (fs.existsSync(filepath)) { 184 const {mtime: inTime} = fs.statSync(filepath); 185 if (+inTime > +outTime) { 186 return true; 187 } 188 } 189 else { 190 return true; 191 } 192 } 193 return false; 194 } 195 } 196 else if (typeof source !== "string" && typeof dest !== "string") { 197 for (let i = 0; i < source.length; i++) { 198 if (!dest[i]) { 199 continue; 200 } 201 if (fs.existsSync(dest[i])) { 202 const {mtime: outTime} = fs.statSync(dest[i]); 203 const {mtime: inTime} = fs.statSync(source[i]); 204 if (+inTime > +outTime) { 205 return true; 206 } 207 } 208 else { 209 return true; 210 } 211 } 212 return false; 213 } 214 return true; 215} 216exports.needsUpdate = needsUpdate; 217 218function getDiffTool() { 219 const program = process.env.DIFF; 220 if (!program) { 221 log.warn("Add the 'DIFF' environment variable to the path of the program you want to use."); 222 process.exit(1); 223 } 224 return program; 225} 226exports.getDiffTool = getDiffTool; 227 228/** 229 * Find the size of a directory recursively. 230 * Symbolic links can cause a loop. 231 * @param {string} root 232 * @returns {number} bytes 233 */ 234function getDirSize(root) { 235 const stats = fs.lstatSync(root); 236 237 if (!stats.isDirectory()) { 238 return stats.size; 239 } 240 241 return fs.readdirSync(root) 242 .map(file => getDirSize(path.join(root, file))) 243 .reduce((acc, num) => acc + num, 0); 244} 245exports.getDirSize = getDirSize; 246 247/** 248 * Flattens a project with project references into a single project. 249 * @param {string} projectSpec The path to a tsconfig.json file or its containing directory. 250 * @param {string} flattenedProjectSpec The output path for the flattened tsconfig.json file. 251 * @param {FlattenOptions} [options] Options used to flatten a project hierarchy. 252 * 253 * @typedef FlattenOptions 254 * @property {string} [cwd] The path to use for the current working directory. Defaults to `process.cwd()`. 255 * @property {import("../../lib/typescript").CompilerOptions} [compilerOptions] Compiler option overrides. 256 * @property {boolean} [force] Forces creation of the output project. 257 * @property {string[]} [exclude] Files to exclude (relative to `cwd`) 258 */ 259function flatten(projectSpec, flattenedProjectSpec, options = {}) { 260 const cwd = normalizeSlashes(options.cwd ? path.resolve(options.cwd) : process.cwd()); 261 const files = []; 262 const resolvedOutputSpec = path.resolve(cwd, flattenedProjectSpec); 263 const resolvedOutputDirectory = path.dirname(resolvedOutputSpec); 264 const resolvedProjectSpec = resolveProjectSpec(projectSpec, cwd, undefined); 265 const project = readJson(resolvedProjectSpec); 266 const skipProjects = /**@type {Set<string>}*/(new Set()); 267 const skipFiles = new Set(options && options.exclude && options.exclude.map(file => normalizeSlashes(path.resolve(cwd, file)))); 268 recur(resolvedProjectSpec, project); 269 270 if (options.force || needsUpdate(files, resolvedOutputSpec)) { 271 const config = { 272 extends: normalizeSlashes(path.relative(resolvedOutputDirectory, resolvedProjectSpec)), 273 compilerOptions: options.compilerOptions || {}, 274 files: files.map(file => normalizeSlashes(path.relative(resolvedOutputDirectory, file))) 275 }; 276 mkdirp.sync(resolvedOutputDirectory); 277 fs.writeFileSync(resolvedOutputSpec, JSON.stringify(config, undefined, 2), "utf8"); 278 } 279 280 /** 281 * @param {string} projectSpec 282 * @param {object} project 283 */ 284 function recur(projectSpec, project) { 285 if (skipProjects.has(projectSpec)) return; 286 skipProjects.add(project); 287 if (project.references) { 288 for (const ref of project.references) { 289 const referencedSpec = resolveProjectSpec(ref.path, cwd, projectSpec); 290 const referencedProject = readJson(referencedSpec); 291 recur(referencedSpec, referencedProject); 292 } 293 } 294 if (project.include) { 295 throw new Error("Flattened project may not have an 'include' list."); 296 } 297 if (!project.files) { 298 throw new Error("Flattened project must have an explicit 'files' list."); 299 } 300 const projectDirectory = path.dirname(projectSpec); 301 for (let file of project.files) { 302 file = normalizeSlashes(path.resolve(projectDirectory, file)); 303 if (skipFiles.has(file)) continue; 304 skipFiles.add(file); 305 files.push(file); 306 } 307 } 308} 309exports.flatten = flatten; 310 311/** 312 * @param {string} file 313 */ 314function normalizeSlashes(file) { 315 return file.replace(/\\/g, "/"); 316} 317 318/** 319 * @param {string} projectSpec 320 * @param {string} cwd 321 * @param {string | undefined} referrer 322 * @returns {string} 323 */ 324function resolveProjectSpec(projectSpec, cwd, referrer) { 325 let projectPath = normalizeSlashes(path.resolve(cwd, referrer ? path.dirname(referrer) : "", projectSpec)); 326 const stats = fs.statSync(projectPath); 327 if (stats.isFile()) return normalizeSlashes(projectPath); 328 return normalizeSlashes(path.resolve(cwd, projectPath, "tsconfig.json")); 329} 330 331/** 332 * @param {string | ((file: File) => string) | { cwd?: string }} [dest] 333 * @param {{ cwd?: string }} [opts] 334 */ 335function rm(dest, opts) { 336 if (dest && typeof dest === "object") opts = dest, dest = undefined; 337 let failed = false; 338 339 const cwd = path.resolve(opts && opts.cwd || process.cwd()); 340 341 /** @type {{ file: File, deleted: boolean, promise: Promise<any>, cb: Function }[]} */ 342 const pending = []; 343 344 const processDeleted = () => { 345 if (failed) return; 346 while (pending.length && pending[0].deleted) { 347 const { file, cb } = pending.shift(); 348 duplex.push(file); 349 cb(); 350 } 351 }; 352 353 const duplex = new Duplex({ 354 objectMode: true, 355 /** 356 * @param {string|Buffer|File} file 357 */ 358 write(file, _, cb) { 359 if (failed) return; 360 if (typeof file === "string" || Buffer.isBuffer(file)) return cb(new Error("Only Vinyl files are supported.")); 361 const basePath = typeof dest === "string" ? path.resolve(cwd, dest) : 362 typeof dest === "function" ? path.resolve(cwd, dest(file)) : 363 file.base; 364 const filePath = path.resolve(basePath, file.relative); 365 file.cwd = cwd; 366 file.base = basePath; 367 file.path = filePath; 368 const entry = { 369 file, 370 deleted: false, 371 cb, 372 promise: del(file.path).then(() => { 373 entry.deleted = true; 374 processDeleted(); 375 }, err => { 376 failed = true; 377 pending.length = 0; 378 cb(err); 379 }) 380 }; 381 pending.push(entry); 382 }, 383 final(cb) { 384 const endThenCb = () => (duplex.push(null), cb()); // signal end of read queue 385 processDeleted(); 386 if (pending.length) { 387 Promise 388 .all(pending.map(entry => entry.promise)) 389 .then(() => processDeleted()) 390 .then(() => endThenCb(), endThenCb); 391 return; 392 } 393 endThenCb(); 394 }, 395 read() { 396 } 397 }); 398 return duplex; 399} 400exports.rm = rm; 401 402class Debouncer { 403 /** 404 * @param {number} timeout 405 * @param {() => Promise<any>} action 406 */ 407 constructor(timeout, action) { 408 this._timeout = timeout; 409 this._action = action; 410 } 411 412 enqueue() { 413 if (this._timer) { 414 clearTimeout(this._timer); 415 this._timer = undefined; 416 } 417 418 if (!this._deferred) { 419 this._deferred = new Deferred(); 420 } 421 422 this._timer = setTimeout(() => this.run(), 100); 423 return this._deferred.promise; 424 } 425 426 run() { 427 if (this._timer) { 428 clearTimeout(this._timer); 429 this._timer = undefined; 430 } 431 432 const deferred = this._deferred; 433 this._deferred = undefined; 434 this._projects = undefined; 435 try { 436 deferred.resolve(this._action()); 437 } 438 catch (e) { 439 deferred.reject(e); 440 } 441 } 442} 443exports.Debouncer = Debouncer;