CLI.ts (17511B)
1 /** 2 * @license 3 * Copyright 2023 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 7 import {stdin as input, stdout as output} from 'node:process'; 8 import * as readline from 'node:readline'; 9 10 import type * as Yargs from 'yargs'; 11 12 import { 13 resolveBuildId, 14 type Browser, 15 BrowserPlatform, 16 type ChromeReleaseChannel, 17 } from './browser-data/browser-data.js'; 18 import {Cache} from './Cache.js'; 19 import {detectBrowserPlatform} from './detectPlatform.js'; 20 import {packageVersion} from './generated/version.js'; 21 import {install} from './install.js'; 22 import { 23 computeExecutablePath, 24 computeSystemExecutablePath, 25 launch, 26 } from './launch.js'; 27 28 interface InstallBrowser { 29 name: Browser; 30 buildId: string; 31 } 32 interface InstallArgs { 33 browser?: InstallBrowser; 34 path?: string; 35 platform?: BrowserPlatform; 36 baseUrl?: string; 37 installDeps?: boolean; 38 } 39 40 /** 41 * @public 42 */ 43 export class CLI { 44 #cachePath: string; 45 #rl?: readline.Interface; 46 #scriptName: string; 47 #version: string; 48 #allowCachePathOverride: boolean; 49 #pinnedBrowsers?: Partial< 50 Record< 51 Browser, 52 { 53 buildId: string; 54 skipDownload: boolean; 55 } 56 > 57 >; 58 #prefixCommand?: {cmd: string; description: string}; 59 60 constructor( 61 opts?: 62 | string 63 | { 64 cachePath?: string; 65 scriptName?: string; 66 version?: string; 67 prefixCommand?: {cmd: string; description: string}; 68 allowCachePathOverride?: boolean; 69 pinnedBrowsers?: Partial< 70 Record< 71 Browser, 72 { 73 buildId: string; 74 skipDownload: boolean; 75 } 76 > 77 >; 78 }, 79 rl?: readline.Interface, 80 ) { 81 if (!opts) { 82 opts = {}; 83 } 84 if (typeof opts === 'string') { 85 opts = { 86 cachePath: opts, 87 }; 88 } 89 this.#cachePath = opts.cachePath ?? process.cwd(); 90 this.#rl = rl; 91 this.#scriptName = opts.scriptName ?? '@puppeteer/browsers'; 92 this.#version = opts.version ?? packageVersion; 93 this.#allowCachePathOverride = opts.allowCachePathOverride ?? true; 94 this.#pinnedBrowsers = opts.pinnedBrowsers; 95 this.#prefixCommand = opts.prefixCommand; 96 } 97 98 #defineBrowserParameter<T>( 99 yargs: Yargs.Argv<T>, 100 required: true, 101 ): Yargs.Argv<T & {browser: InstallBrowser}>; 102 #defineBrowserParameter<T>( 103 yargs: Yargs.Argv<T>, 104 required: boolean, 105 ): Yargs.Argv<T & {browser: InstallBrowser | undefined}>; 106 #defineBrowserParameter<T>(yargs: Yargs.Argv<T>, required: boolean) { 107 return yargs.positional('browser', { 108 description: 109 'Which browser to install <browser>[@<buildId|latest>]. `latest` will try to find the latest available build. `buildId` is a browser-specific identifier such as a version or a revision.', 110 type: 'string', 111 coerce: (opt): InstallBrowser => { 112 return { 113 name: this.#parseBrowser(opt), 114 buildId: this.#parseBuildId(opt), 115 }; 116 }, 117 demandOption: required, 118 }); 119 } 120 121 #definePlatformParameter<T>(yargs: Yargs.Argv<T>) { 122 return yargs.option('platform', { 123 type: 'string', 124 desc: 'Platform that the binary needs to be compatible with.', 125 choices: Object.values(BrowserPlatform), 126 defaultDescription: 'Auto-detected', 127 }); 128 } 129 130 #definePathParameter<T>(yargs: Yargs.Argv<T>, required = false) { 131 if (!this.#allowCachePathOverride) { 132 return yargs as Yargs.Argv<T & {path: undefined}>; 133 } 134 135 return yargs.option('path', { 136 type: 'string', 137 desc: 'Path to the root folder for the browser downloads and installation. If a relative path is provided, it will be resolved relative to the current working directory. The installation folder structure is compatible with the cache structure used by Puppeteer.', 138 defaultDescription: 'Current working directory', 139 ...(required ? {} : {default: process.cwd()}), 140 demandOption: required, 141 }); 142 } 143 144 async run(argv: string[]): Promise<void> { 145 const {default: yargs} = await import('yargs'); 146 const {hideBin} = await import('yargs/helpers'); 147 const yargsInstance = yargs(hideBin(argv)); 148 let target = yargsInstance 149 .scriptName(this.#scriptName) 150 .version(this.#version); 151 if (this.#prefixCommand) { 152 target = target.command( 153 this.#prefixCommand.cmd, 154 this.#prefixCommand.description, 155 yargs => { 156 return this.#build(yargs); 157 }, 158 ); 159 } else { 160 target = this.#build(target); 161 } 162 await target 163 .demandCommand(1) 164 .help() 165 .wrap(Math.min(120, yargsInstance.terminalWidth())) 166 .parseAsync(); 167 } 168 169 #build(yargs: Yargs.Argv<unknown>) { 170 const latestOrPinned = this.#pinnedBrowsers ? 'pinned' : 'latest'; 171 // If there are pinned browsers allow the positional arg to be optional 172 const browserArgType = this.#pinnedBrowsers ? '[browser]' : '<browser>'; 173 return yargs 174 .command( 175 `install ${browserArgType}`, 176 'Download and install the specified browser. If successful, the command outputs the actual browser buildId that was installed and the absolute path to the browser executable (format: <browser>@<buildID> <path>).', 177 yargs => { 178 if (this.#pinnedBrowsers) { 179 yargs.example('$0 install', 'Install all pinned browsers'); 180 } 181 yargs 182 .example( 183 '$0 install chrome', 184 `Install the ${latestOrPinned} available build of the Chrome browser.`, 185 ) 186 .example( 187 '$0 install chrome@latest', 188 'Install the latest available build for the Chrome browser.', 189 ) 190 .example( 191 '$0 install chrome@stable', 192 'Install the latest available build for the Chrome browser from the stable channel.', 193 ) 194 .example( 195 '$0 install chrome@beta', 196 'Install the latest available build for the Chrome browser from the beta channel.', 197 ) 198 .example( 199 '$0 install chrome@dev', 200 'Install the latest available build for the Chrome browser from the dev channel.', 201 ) 202 .example( 203 '$0 install chrome@canary', 204 'Install the latest available build for the Chrome Canary browser.', 205 ) 206 .example( 207 '$0 install chrome@115', 208 'Install the latest available build for Chrome 115.', 209 ) 210 .example( 211 '$0 install chromedriver@canary', 212 'Install the latest available build for ChromeDriver Canary.', 213 ) 214 .example( 215 '$0 install chromedriver@115', 216 'Install the latest available build for ChromeDriver 115.', 217 ) 218 .example( 219 '$0 install chromedriver@115.0.5790', 220 'Install the latest available patch (115.0.5790.X) build for ChromeDriver.', 221 ) 222 .example( 223 '$0 install chrome-headless-shell', 224 'Install the latest available chrome-headless-shell build.', 225 ) 226 .example( 227 '$0 install chrome-headless-shell@beta', 228 'Install the latest available chrome-headless-shell build corresponding to the Beta channel.', 229 ) 230 .example( 231 '$0 install chrome-headless-shell@118', 232 'Install the latest available chrome-headless-shell 118 build.', 233 ) 234 .example( 235 '$0 install chromium@1083080', 236 'Install the revision 1083080 of the Chromium browser.', 237 ) 238 .example( 239 '$0 install firefox', 240 'Install the latest nightly available build of the Firefox browser.', 241 ) 242 .example( 243 '$0 install firefox@stable', 244 'Install the latest stable build of the Firefox browser.', 245 ) 246 .example( 247 '$0 install firefox@beta', 248 'Install the latest beta build of the Firefox browser.', 249 ) 250 .example( 251 '$0 install firefox@devedition', 252 'Install the latest devedition build of the Firefox browser.', 253 ) 254 .example( 255 '$0 install firefox@esr', 256 'Install the latest ESR build of the Firefox browser.', 257 ) 258 .example( 259 '$0 install firefox@nightly', 260 'Install the latest nightly build of the Firefox browser.', 261 ) 262 .example( 263 '$0 install firefox@stable_111.0.1', 264 'Install a specific version of the Firefox browser.', 265 ) 266 .example( 267 '$0 install firefox --platform mac', 268 'Install the latest Mac (Intel) build of the Firefox browser.', 269 ); 270 if (this.#allowCachePathOverride) { 271 yargs.example( 272 '$0 install firefox --path /tmp/my-browser-cache', 273 'Install to the specified cache directory.', 274 ); 275 } 276 277 const yargsWithBrowserParam = this.#defineBrowserParameter( 278 yargs, 279 !Boolean(this.#pinnedBrowsers), 280 ); 281 const yargsWithPlatformParam = this.#definePlatformParameter( 282 yargsWithBrowserParam, 283 ); 284 return this.#definePathParameter(yargsWithPlatformParam, false) 285 .option('base-url', { 286 type: 'string', 287 desc: 'Base URL to download from', 288 }) 289 .option('install-deps', { 290 type: 'boolean', 291 desc: 'Whether to attempt installing system dependencies (only supported on Linux, requires root privileges).', 292 default: false, 293 }); 294 }, 295 async args => { 296 if (this.#pinnedBrowsers && !args.browser) { 297 // Use allSettled to avoid scenarios that 298 // a browser may fail early and leave the other 299 // installation in a faulty state 300 const result = await Promise.allSettled( 301 Object.entries(this.#pinnedBrowsers).map( 302 async ([browser, options]) => { 303 if (options.skipDownload) { 304 return; 305 } 306 await this.#install({ 307 ...args, 308 browser: { 309 name: browser as Browser, 310 buildId: options.buildId, 311 }, 312 }); 313 }, 314 ), 315 ); 316 317 for (const install of result) { 318 if (install.status === 'rejected') { 319 throw install.reason; 320 } 321 } 322 } else { 323 await this.#install(args); 324 } 325 }, 326 ) 327 .command( 328 'launch <browser>', 329 'Launch the specified browser', 330 yargs => { 331 yargs 332 .example( 333 '$0 launch chrome@115.0.5790.170', 334 'Launch Chrome 115.0.5790.170', 335 ) 336 .example( 337 '$0 launch firefox@112.0a1', 338 'Launch the Firefox browser identified by the milestone 112.0a1.', 339 ) 340 .example( 341 '$0 launch chrome@115.0.5790.170 --detached', 342 'Launch the browser but detach the sub-processes.', 343 ) 344 .example( 345 '$0 launch chrome@canary --system', 346 'Try to locate the Canary build of Chrome installed on the system and launch it.', 347 ) 348 .example( 349 '$0 launch chrome@115.0.5790.170 -- --version', 350 'Launch Chrome 115.0.5790.170 and pass custom argument to the binary.', 351 ); 352 353 const yargsWithExtraAgs = yargs.parserConfiguration({ 354 'populate--': true, 355 // Yargs does not have the correct overload for this. 356 }) as Yargs.Argv<{'--'?: Array<string | number>}>; 357 const yargsWithBrowserParam = this.#defineBrowserParameter( 358 yargsWithExtraAgs, 359 true, 360 ); 361 const yargsWithPlatformParam = this.#definePlatformParameter( 362 yargsWithBrowserParam, 363 ); 364 return this.#definePathParameter(yargsWithPlatformParam) 365 .option('detached', { 366 type: 'boolean', 367 desc: 'Detach the child process.', 368 default: false, 369 }) 370 .option('system', { 371 type: 'boolean', 372 desc: 'Search for a browser installed on the system instead of the cache folder.', 373 default: false, 374 }) 375 .option('dumpio', { 376 type: 'boolean', 377 desc: "Forwards the browser's process stdout and stderr", 378 default: false, 379 }); 380 }, 381 async args => { 382 const extraArgs = args['--']?.filter(arg => { 383 return typeof arg === 'string'; 384 }); 385 386 const executablePath = args.system 387 ? computeSystemExecutablePath({ 388 browser: args.browser.name, 389 // TODO: throw an error if not a ChromeReleaseChannel is provided. 390 channel: args.browser.buildId as ChromeReleaseChannel, 391 platform: args.platform, 392 }) 393 : computeExecutablePath({ 394 browser: args.browser.name, 395 buildId: args.browser.buildId, 396 cacheDir: args.path ?? this.#cachePath, 397 platform: args.platform, 398 }); 399 launch({ 400 args: extraArgs, 401 executablePath, 402 dumpio: args.dumpio, 403 detached: args.detached, 404 }); 405 }, 406 ) 407 .command( 408 'clear', 409 this.#allowCachePathOverride 410 ? 'Removes all installed browsers from the specified cache directory' 411 : `Removes all installed browsers from ${this.#cachePath}`, 412 yargs => { 413 return this.#definePathParameter(yargs, true); 414 }, 415 async args => { 416 const cacheDir = args.path ?? this.#cachePath; 417 const rl = this.#rl ?? readline.createInterface({input, output}); 418 rl.question( 419 `Do you want to permanently and recursively delete the content of ${cacheDir} (yes/No)? `, 420 answer => { 421 rl.close(); 422 if (!['y', 'yes'].includes(answer.toLowerCase().trim())) { 423 console.log('Cancelled.'); 424 return; 425 } 426 const cache = new Cache(cacheDir); 427 cache.clear(); 428 console.log(`${cacheDir} cleared.`); 429 }, 430 ); 431 }, 432 ) 433 .command( 434 'list', 435 'List all installed browsers in the cache directory', 436 yargs => { 437 yargs.example( 438 '$0 list', 439 'List all installed browsers in the cache directory', 440 ); 441 if (this.#allowCachePathOverride) { 442 yargs.example( 443 '$0 list --path /tmp/my-browser-cache', 444 'List browsers installed in the specified cache directory', 445 ); 446 } 447 448 return this.#definePathParameter(yargs); 449 }, 450 async args => { 451 const cacheDir = args.path ?? this.#cachePath; 452 const cache = new Cache(cacheDir); 453 const browsers = cache.getInstalledBrowsers(); 454 455 for (const browser of browsers) { 456 console.log( 457 `${browser.browser}@${browser.buildId} (${browser.platform}) ${browser.executablePath}`, 458 ); 459 } 460 }, 461 ) 462 .demandCommand(1) 463 .help(); 464 } 465 466 #parseBrowser(version: string): Browser { 467 return version.split('@').shift() as Browser; 468 } 469 470 #parseBuildId(version: string): string { 471 const parts = version.split('@'); 472 return parts.length === 2 473 ? parts[1]! 474 : this.#pinnedBrowsers 475 ? 'pinned' 476 : 'latest'; 477 } 478 479 async #install(args: InstallArgs) { 480 args.platform ??= detectBrowserPlatform(); 481 if (!args.browser) { 482 throw new Error(`No browser arg provided`); 483 } 484 if (!args.platform) { 485 throw new Error(`Could not resolve the current platform`); 486 } 487 if (args.browser.buildId === 'pinned') { 488 const options = this.#pinnedBrowsers?.[args.browser.name]; 489 if (!options || !options.buildId) { 490 throw new Error(`No pinned version found for ${args.browser.name}`); 491 } 492 args.browser.buildId = options.buildId; 493 } 494 const originalBuildId = args.browser.buildId; 495 args.browser.buildId = await resolveBuildId( 496 args.browser.name, 497 args.platform, 498 args.browser.buildId, 499 ); 500 await install({ 501 browser: args.browser.name, 502 buildId: args.browser.buildId, 503 platform: args.platform, 504 cacheDir: args.path ?? this.#cachePath, 505 downloadProgressCallback: 'default', 506 baseUrl: args.baseUrl, 507 buildIdAlias: 508 originalBuildId !== args.browser.buildId ? originalBuildId : undefined, 509 installDeps: args.installDeps, 510 }); 511 console.log( 512 `${args.browser.name}@${args.browser.buildId} ${computeExecutablePath({ 513 browser: args.browser.name, 514 buildId: args.browser.buildId, 515 cacheDir: args.path ?? this.#cachePath, 516 platform: args.platform, 517 })}`, 518 ); 519 } 520 }