Nessuna descrizione
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

firepit.js 40KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075
  1. /*
  2. -------------------------------------
  3. Introduction
  4. -------------------------------------
  5. "This is probably the scariest 1000 lines of code I have ever seen" - Sam Stern
  6. Welcome to Firepit! This script (and it's siblings) is a bundle of magical
  7. code which allow the firebase-tools package to run on a developer's machine without
  8. a dependency on Node.js as a single, standalone binary.
  9. If firebase-tools was a simpler tool, Firepit would also be simpler, however... it's
  10. not. The "firebase" command relies on a few patterns which make bundling it without
  11. Node.js particularly difficult, specifically it enjoys shelling out to npm / node.
  12. Most of the work in this package is to properly ensure that those commands (npm, node)
  13. exist and function as expected even when deep in multiple layers of shelling.
  14. Some examples of how shelling is used...
  15. 1) Running any "firebase" command will automatically call npm to check is the "firebase-tools"
  16. package itself is outdated.
  17. 2) Running "firebase deploy --only functions" uses npm to build and prepare the developer's
  18. Cloud Functions code.
  19. 3) Developer's Cloud Functions may require being built with Typescript or other tools which require
  20. access to Node / npm
  21. The majority of firebase-tools commands work perfectly with minimal effort from Firepit,
  22. specifically any JavaScript-only commands (which are most) work totally fine. Most of the
  23. complexity is related to building and deploying Cloud Functions.
  24. Firepit's job isn't *just* to ensure all commands work, it also simplifies the getting
  25. started flows for developers by offering a "hand-holding" setup (see welcome.js) and
  26. improving what we call the "double-click" experience (when a developer downloads the file and
  27. clicks it to run).
  28. Beyond that Firepit also puts extra effort into ensuring that *any* "firebase" related command
  29. will still function if copy/pasted from existing tutorials. Specifically, if the internet says
  30. running "npm update -g firebase-tools" will update your CLI, then the internet must be right and
  31. we need to support that.
  32. This code is generally very carefully written with special care given to cross platform compatibility.
  33. We avoid many cross-platform problems by getting *back* into Node as soon as possible. We'll talk
  34. more about this below, but most code which helps Firepit work cross-platform is not platform-specific
  35. code, but in fact uses Node's natural cross-platform tools / libraries to help out as much as possible.
  36. We'll discuss this more in detail below.
  37. Ready? Let's go!
  38. */
  39. /*
  40. -------------------------------------
  41. Globals
  42. -------------------------------------
  43. Our dependencies are largely uninteresting, we use "user-home" to know where to install our scripts
  44. and files to, we use "chalk" for nice colors, and we use a handful of built in libraries for
  45. their intended purposes.
  46. The most interesting dep is "shelljs". This library is a collection of Unix-style commands like
  47. (cat, ls, mkdir, etc) which are reimplemented in cross-platform JavaScript. They function
  48. identically across platforms and help us whenever we're dealing with the filesystem. The names
  49. are universal and easy to understand for anyone with a *nix background.
  50. We also include our own package.json so we can report the Firepit version to Google Analytics.
  51. */
  52. const fs = require("fs");
  53. const path = require("path");
  54. const { fork, spawn } = require("child_process");
  55. const homePath = require("user-home");
  56. const chalk = require("chalk");
  57. const shell = require("shelljs");
  58. shell.config.silent = true;
  59. const version = require("./package.json").version;
  60. /*
  61. Our only other require, the "./runtime.js" file, is worth discussing in detail. The script itself
  62. is documented in itself, so you're welcome to read that, however the more important topic is the
  63. general structure Firepit uses.
  64. Firepit loops back into itself constantly and is essentially a router which ensures that incoming
  65. invocations end up calling the correct scripts using the embedded Node runtime. A Firepit binary
  66. doesn't include *just* the "firebase" command, it also includes "npm" and "node" because these
  67. are needed by "firebase-tools" to be fully functional. When running in headful (double-click)
  68. mode these commands are exposed to the developer, they can run "npm" just like they would with
  69. a normal Node install, however internally it's not *really* npm, they're invoking a shell script
  70. which comes back into a new Firepit process and is then routed to the npm scripts.
  71. When you're not running Firepit in headful mode, these sub-commands can still be accessed via
  72. hidden flags...
  73. firebase is:npm install -g chalk // Calls npm
  74. firebase is:node ./script.js // Calls node
  75. firebase --help // Calls firebase-tools
  76. These hidden flags aren't intended to be used by end-developers, they're needed because we're
  77. constantly hoping out of the Firepit process. For example Firepit spawn a shell, the shell calls
  78. "npm" (which is actually a new Firepit process) which calls the npm scripts which invokes a user's
  79. build script which spawns a node process (which is actually a new Firepit process) and so on.
  80. We use these special flags to give context between invocations and ask Firepit to imitate whatever
  81. tool the user wants to call. (See Imitate*() functions)
  82. In order to allow ensure that the "node", "npm", and "firebase" commands exist through all
  83. these processes we can do two things.
  84. 1) We can modify env variables like PATH to place our scripts in place of actual tools
  85. 2) We can pass special flags to the tools we're pretending to be so they tell their children
  86. that the world is how we want them to think it is.
  87. Technically (and on a high level) When a developer runs Firepit we go through a series of steps.
  88. 1) If needed, extract the copy of "firebase-tools" which is embedded in the binary file
  89. (see SetupFirebaseTools())
  90. 2) Generate a series of "runtime" scripts which get called from other processes. These scripts
  91. look to the developer like the "npm" or "node" commands, but actually route back into Firepit
  92. and are redirected to the embedded tools.
  93. (see createRuntimeBinaries())
  94. 3) Determine how we can access our embedded NodeJS runtime
  95. (see VerifyNodePath())
  96. 4) Modify the developers env variables to include the "runtime" scripts and other changes
  97. (see firepit())
  98. 5) Route the invocation to the correct command (firebase, npm, or node).
  99. 6) Exit with the correct code and go to bed.
  100. The "runtime.js" script contains two functions. In createRuntimeBinaries() we call .toString()
  101. on these functions and write them to files (which later act like commands on the user's path).
  102. The functions in "runtime.js" are not meant to be invoked from Firepit, but are standalone scripts
  103. which get ran *through* Firepit when it is imitating Node.js.
  104. */
  105. const runtime = require("./runtime");
  106. /*
  107. We use a configuration file (see config.template.js) which is generated by our build pipeline to
  108. determine if we're running in headless or headful mode.
  109. */
  110. let config;
  111. try {
  112. config = require("./config");
  113. } catch (err) {
  114. console.warn("Invalid Firepit configuration, this may be a broken build.");
  115. process.exit(2);
  116. }
  117. const isWindows = process.platform === "win32";
  118. /*
  119. The installPath is where we'll place our extracted firebase-tools scripts.
  120. The runtimeBinsPath is where we place our "npm" and "node" shell scripts which route back into
  121. Firepit.
  122. */
  123. const installPath = path.join(homePath, ".cache", "firebase", "tools");
  124. let runtimeBinsPath = path.join(homePath, ".cache", "firebase", "runtime");
  125. /*
  126. As I mentioned above, one of the ways we can control the detached children processes which get
  127. created when using Firepit is to pass special arguments when we're pretending to be them.
  128. In this case, when a user calls "npm" (and it routes to Firepit, pretending to be npm) we tack
  129. on a few scripts which change the global config file to point to our custom installPath and
  130. we supply a special "script shell".
  131. This "script shell" is normally something like "bash" or "cmd.exe", however in our case, we want
  132. to inject Firepit into there again to ensure everyone thinks the commands we're exposing still exist.
  133. You can see the implementation of this script in runtime.js/Script_ShellJS().
  134. When npm invokes a script on behalf of the developer (like when they run "npm run build") this
  135. command is then spawned in npm as "$SCRIPT_SHELL $USER_SCRIPT" so by replacing the this shell
  136. we can set up env variables / PATHs / etc then spawn the $USER_SCRIPT manually so the behavior
  137. looks no different.
  138. We use these base npmArgs every time we pretend to be npm. They can be overwritten by a user if
  139. they manually specify any of these flags and that would produce unexpected behavior.
  140. */
  141. const npmArgs = [
  142. `--script-shell=${runtimeBinsPath}/shell${isWindows ? ".bat" : ""}`,
  143. `--globalconfig=${path.join(runtimeBinsPath, "npmrc")}`,
  144. `--userconfig=${path.join(runtimeBinsPath, "npmrc")}`,
  145. `--scripts-prepend-node-path=auto`
  146. ];
  147. /*
  148. Windows is terrible and through-out Firepit you'll see references to "safe" and "unsafe" paths.
  149. Unsafe paths, on Windows, are ones with things like spaces in them - yes spaces break stuff.
  150. There is debate about who is at fault. It may be npm, it may be Node, it may be Microsoft, regardless
  151. if your username on Windows (for example) has a space in it, it'll break everything.
  152. Luckily because we control the universe in Firepit, we can use a crazy hack to replace any
  153. evil (i.e. space-inclusive) paths with DOS (yes DOS) style paths. See getSafeCrossPlatformPath()
  154. For example:
  155. unsafePath: C:\Program Files\Java\jdk1.6.0_22
  156. safePath: C:\PROGRA~1\Java\JDK16~1.0_2
  157. We use the safePath when needed (specifically when passing them through cmd.exe) to reduce the
  158. chances of space-related bugs.
  159. This is needed *all* the time, but it's pretty common in here.
  160. */
  161. let safeNodePath;
  162. const unsafeNodePath = process.argv[0];
  163. /*
  164. Firepit supports some additional flags that the firebase command does not. These flags are
  165. generally used internally when Firepit invokes Firepit (for example, during welcome.js).
  166. If you want to run any of these flags, invoke Firepit with --tool:$COMMAND
  167. */
  168. const flagDefinitions = [
  169. "file-debug", // --tool:file-debug - Write log to a file
  170. "log-debug", // --tool:log-debug - Write log to stdout
  171. "disable-write", // --tool:disable-write - Do not write runtime scripts to filesystem
  172. "runtime-check", // --tool:runtime-check - Determine if firepit binary is node or not (see VerifyNodePath())
  173. "setup-check", // --tool:setup-check - Check if firebase-tools is set up
  174. "force-setup", // --tool:force-setup - Force Firepit to go through setup
  175. "force-update", // --tool:force-update - Aggressively clear npm cache and re-setup
  176. "ignore-embedded-cache" // --tool:ignore-embedded-cache - Setup from online, do not use embedded firebase-tools
  177. ];
  178. /*
  179. This script parses our flagDefinitions and returns a map like {file-debug: false, ...}
  180. */
  181. const flags = flagDefinitions.reduce((flags, name) => {
  182. flags[name] = process.argv.indexOf(`--tool:${name}`) !== -1;
  183. if (flags[name]) {
  184. process.argv.splice(process.argv.indexOf(`--tool:${name}`), 1);
  185. }
  186. return flags;
  187. }, {});
  188. /*
  189. We use @zeit/pkg to actually bundle our JavaScript with the NodeJS runtime to produce our binaries.
  190. In general if you're running your code inside of pkg and you attempt to spawn the pkg binary which
  191. you invoked to run your code (i.e. firepit.exe invokes firepit.exe) what you'll actually be
  192. invoking is the underlying Node.js binary which is embedded is the binary.
  193. This works well, albeit it may be a bit unexpected, however due to the nature of Firepit,
  194. there's no assurance that we'll actually be in the same process at any given time.
  195. For example, if we invoke "./firepit" and Firepit spawns a shell and that shell is used to call "firebase"
  196. we're now in a situation where invoking "./firepit" from "firebase" will act as a fresh call to
  197. Firepit, resulting it in running through the setup and such.
  198. In another example, if we invoke "./firepit" and it immediately spawns "./firepit" then it'll
  199. be spawning a node process.
  200. I know this is confusing, but the moral is that we can be sure at any moment if spawning "./firepit"
  201. will provide us with this file running in Node or just a Node runtime.
  202. To detect what the "firepit" binary is we run "./firepit check.js --tool:runtime-check" in
  203. VerifyNodePath(). If "./firepit" is acting as Firepit, this conditional will flip and we'll
  204. just exit out. If "./firepit" is acting as a Node runtime, it'll invoke check.js and return
  205. a unicode ✓. This allows us to know if we can safely invoke Node scripts by calling ourselves
  206. or if we must call "./firepit is:node ./script" to force it to manually imitate Node.
  207. */
  208. if (flags["runtime-check"]) {
  209. console.log(`firepit invoked for runtime check, exiting subpit.`);
  210. return;
  211. }
  212. debug(`Welcome to firepit v${version}!`);
  213. /*
  214. -------------------------------------
  215. The Main Path
  216. -------------------------------------
  217. When running Firepit, we start here. This async closure handles checking most of the --tool flags
  218. and ensuring that Firepit is setup and in-place before running firepit()
  219. */
  220. (async () => {
  221. /*
  222. Any time we invoke a child process from Firepit, we tack on a FIREPIT_VERSION env variable.
  223. This is useful here so we can detect if we are the "top level" Firepit instance.
  224. For example, if you are running Firepit in headful mode then the first instance of Firepit
  225. spawns you a command prompt window. In that command prompt we go through welcome.js then
  226. you're given access to the "firebase" command.
  227. When you run "firebase", if we didn't know if we were top-level then you'd just spawn another
  228. command prompt window - clearly not what we want. So we look for the env variable set by the
  229. process which spawned the window. If it exists, we functionally fall into "headless" mode
  230. and act like a normal Firebase CLI.
  231. */
  232. const isTopLevel = !process.env.FIREPIT_VERSION;
  233. /*
  234. As I mentioned above, we make heavy use of this function to DOS-isy paths to avoid space-issues.
  235. In this case, we're using process.argv[0] (always a reference to the node binary which spawned
  236. this script) and turning it safe so we have an invokable Node.js runtime for later.
  237. */
  238. safeNodePath = await getSafeCrossPlatformPath(isWindows, process.argv[0]);
  239. /*
  240. If the user has ever had an older version of Firepit, clear it out and replace it with us.
  241. */
  242. uninstallLegacyFirepit();
  243. /*
  244. --tool:setup-check is used by welcome.js and returns out a JSON list of binaries for the "firebase"
  245. command. It's essentially a check to see if we can find a copy of "firebase" to invoke.
  246. The FindTool function looks in several places for where it thinks our firebase script might be
  247. and returns as many as it fins. We almost always use the 0th one.
  248. */
  249. if (flags["setup-check"]) {
  250. const bins = FindTool("firebase-tools/lib/bin/firebase");
  251. for (const bin of bins) {
  252. bins[bin] = await getSafeCrossPlatformPath(bins[bin]);
  253. }
  254. console.log(JSON.stringify({ bins }));
  255. return;
  256. }
  257. /*
  258. --tool:force-update is never used internally, but can be useful for EAPs where version numbers
  259. may be incorrect. This manually clear NPMs cache and then flips the flags "ignore-embedded-cache"
  260. and "force-setup" to tell Firepit to install itself from the remote package (either a link to a
  261. tgz or just firebase-tools@latest).
  262. */
  263. if (flags["force-update"]) {
  264. console.log(`Please wait while we clear npm's cache...`);
  265. /*
  266. This is the first instance of invoking one of the Imitate*() methods. These methods are
  267. the methods which "route" to the underlying scripts for each command. As you'd expect
  268. ImitateNPM forces the process to act just like npm.
  269. By replacing the process.argv before calling ImitateNPM(), we're rewriting what the
  270. command was which called Firepit. For example, this snippet creates the following command...
  271. /blah/blah/node ./firepit.js is:npm cache clean --force
  272. As far as Firepit is concerned, this looks just like invoking it with is:npm from the top.
  273. It may be cleaner to have Imitate*() take an array of command strings instead of modifying
  274. process.argv, but for now I'll leave it like this.
  275. */
  276. process.argv = [
  277. ...process.argv.slice(0, 2),
  278. "is:npm",
  279. "cache",
  280. "clean",
  281. "--force"
  282. ];
  283. /*
  284. The Imitate*() methods also always return codes (0, 1, 2) from the underlying script. We
  285. need to make sure we bubble these up because incorrect handling of exit codes will create
  286. unexpected behavior in scripts.
  287. */
  288. const code = await ImitateNPM();
  289. if (code) {
  290. console.log("NPM cache clearing failed, can't update.");
  291. process.exit(code);
  292. }
  293. flags["ignore-embedded-cache"] = true;
  294. flags["force-setup"] = true;
  295. console.log(`Clearing out your firebase-tools setup...`);
  296. /*
  297. Here's a handy use of shelljs. It's stupidly hard to recursively remove a directory with
  298. Node's standard libs. Shelljs makes it trivial.
  299. */
  300. shell.rm("-rf", installPath);
  301. }
  302. /*
  303. Every time Firepit is invoked it recreates the runtime binaries (node, npm, shell) because
  304. these binaries need to know the current location of the Firepit binary. See the function
  305. comments for more.
  306. */
  307. await createRuntimeBinaries();
  308. /*
  309. If we're in --tool:force-setup then extract or remotely install firebase-tools then exit out.
  310. */
  311. if (flags["force-setup"]) {
  312. debug("Forcing setup...");
  313. await SetupFirebaseTools();
  314. console.log("firebase-tools setup complete.");
  315. return;
  316. }
  317. /*
  318. As I mentioned above, isTopLevel is basically the same as headless mode. There's an entire flow
  319. here which revolves around invoking "./welcome.js" See that script for more details
  320. */
  321. if (isTopLevel && !config.headless) {
  322. const welcome_path = await getSafeCrossPlatformPath(
  323. isWindows,
  324. path.join(__dirname, "/welcome.js")
  325. );
  326. const firebaseToolsCommand = await getFirebaseToolsCommand();
  327. /*
  328. This function adds a directory onto the PATH env variable. On Windows they're ; separated
  329. and *nix they're : seperated.
  330. */
  331. appendToPath(isWindows, [path.join(installPath, "bin"), runtimeBinsPath]);
  332. /*
  333. As I mentioned above, we set the FIREPIT_VERSION env variable so that the shell we spawn
  334. doesn't spawn another window and it instead acts as a headless firepit.
  335. */
  336. const shellEnv = {
  337. FIREPIT_VERSION: version,
  338. ...process.env
  339. };
  340. if (isWindows) {
  341. /*
  342. This is some of the only platform specific bits we have here. On Windows, headful mode spawns
  343. a custom cmd.exe prompt with doskey (alias) commands called to expose the "firebase" and "npm"
  344. commands. We also set the prompt to a neat yellow ">" then invoke the welcome script.
  345. This top level Firepit script sits open until the developer closes that terminal.
  346. */
  347. const shellConfig = {
  348. stdio: "inherit",
  349. env: shellEnv
  350. };
  351. spawn(
  352. "cmd",
  353. [
  354. "/k",
  355. [
  356. `doskey firebase=${firebaseToolsCommand} $*`,
  357. `doskey npm=${firebaseToolsCommand} is:npm $*`,
  358. `set prompt=${chalk.yellow("$G")}`,
  359. `${firebaseToolsCommand} is:node ${welcome_path} ${firebaseToolsCommand}`
  360. ].join(" & ")
  361. ],
  362. shellConfig
  363. );
  364. process.on("SIGINT", () => {
  365. debug("Received SIGINT. Refusing to close top-level shell.");
  366. });
  367. } else {
  368. /*
  369. If we're not on Windows, then we can technically perform headful mode on Mac. By default double-clicking
  370. a binary on Mac will pop up a terminal, so we just invoke the welcome screen and set the bash prompt.
  371. */
  372. process.argv = [
  373. ...process.argv.slice(0, 2),
  374. "is:node",
  375. welcome_path,
  376. firebaseToolsCommand
  377. ];
  378. const code = await ImitateNode();
  379. if (code) {
  380. console.log("Node failed to run welcome script.");
  381. process.exit(code);
  382. }
  383. spawn("bash", {
  384. env: { ...shellEnv, PS1: "\\e[0;33m> \\e[m" },
  385. stdio: "inherit"
  386. });
  387. }
  388. } else {
  389. /*
  390. In the case that Firepit is not in headful mode (or it was loaded in headful more, but is
  391. not the top level process), then we jump into the actual firepit() method which takes care
  392. of routing the is:npm, is:node, or other core modes.
  393. */
  394. SetWindowTitle("Firebase CLI");
  395. await firepit();
  396. }
  397. if (flags["file-debug"]) {
  398. fs.writeFileSync("firepit-log.txt", debug.log.join("\n"));
  399. }
  400. })().catch(err => {
  401. /*
  402. Note we have a high-level catch here which attempts to catch any crazy firepit errors. This is
  403. rarely hit, but it will produce a firepit-log.txt when some internal errors occur.
  404. */
  405. debug(err.toString());
  406. console.log(
  407. `This tool has encountered an error. Please file a bug on Github (https://github.com/firebase/firebase-tools/) and include firepit-log.txt`
  408. );
  409. fs.writeFileSync("firepit-log.txt", debug.log.join("\n"));
  410. });
  411. async function firepit() {
  412. /*
  413. When running inside Node, the "node" binary is stored in many places. As I mentioned earlier,
  414. it's the 0th item of process.argv and it's also in a couple other places. To be safe we
  415. get a "safe" version of the Node runtime path and replace all known references with this.
  416. */
  417. runtimeBinsPath = await getSafeCrossPlatformPath(isWindows, runtimeBinsPath);
  418. // TODO: I'm not sure this is needed, more testing would be useful.
  419. process.argv[0] = safeNodePath;
  420. process.env.NODE = safeNodePath;
  421. process.env._ = safeNodePath;
  422. debug(safeNodePath);
  423. debug(process.argv);
  424. // TODO: This may not be needed since we invoke createRuntimeBinaries() earlier
  425. await createRuntimeBinaries();
  426. appendToPath(isWindows, [runtimeBinsPath]);
  427. /*
  428. We check for the is:npm and is:node flags and if either exist, we opt ot imitate that process
  429. and then exit out when done.
  430. */
  431. if (process.argv.indexOf("is:npm") !== -1) {
  432. const code = await ImitateNPM();
  433. process.exit(code);
  434. }
  435. if (process.argv.indexOf("is:node") !== -1) {
  436. const code = await ImitateNode();
  437. process.exit(code);
  438. }
  439. /*
  440. If Firepit was invoked in headless mode, there is a chance that firebase-tools has not been set
  441. up yet (since the welcome screen was never shown and that script is what calls --tool:forces-setup.
  442. To be sure, we attempt to find the firebase-tools script and if it's not found, we attempt a setup.
  443. After the setup, if the script still isn't found then something is wrong and we die.
  444. */
  445. let firebaseBins = FindTool("firebase-tools/lib/bin/firebase");
  446. if (!firebaseBins.length) {
  447. debug(`CLI not found! Invoking setup...`);
  448. await SetupFirebaseTools();
  449. firebaseBins = FindTool("firebase-tools/lib/bin/firebase");
  450. }
  451. /*
  452. Assuming we've gotten this far, we've found the CLI and we're ready to run firebase-tools.
  453. That was easy, huh?
  454. */
  455. const firebaseBin = firebaseBins[0];
  456. debug(`CLI install found at "${firebaseBin}", starting fork...`);
  457. const code = await ImitateFirebaseTools(firebaseBin);
  458. process.exit(code);
  459. }
  460. /*
  461. -------------------------------------
  462. Imitate*()
  463. -------------------------------------
  464. All of the Imitate*() methods are very similar. For is:npm and is:node we break process.argv
  465. based on that string and then pass everything on the right to the script, which is forked from
  466. the main Node process. We create a promise (which can be awaited) and then resolve when the
  467. command is done.
  468. */
  469. function ImitateNPM() {
  470. debug("Detected is:npm flag, calling NPM");
  471. const breakerIndex = process.argv.indexOf("is:npm") + 1;
  472. const args = [...npmArgs, ...process.argv.slice(breakerIndex)];
  473. debug(args.join(" "));
  474. return new Promise(resolve => {
  475. const cmd = fork(FindTool("npm/bin/npm-cli")[0], args, {
  476. stdio: "inherit",
  477. env: process.env
  478. });
  479. cmd.on("close", code => {
  480. debug(`faux-npm done.`);
  481. resolve(code);
  482. });
  483. });
  484. }
  485. function ImitateNode() {
  486. debug("Detected is:node flag, calling node");
  487. const breakerIndex = process.argv.indexOf("is:node") + 1;
  488. const nodeArgs = [...process.argv.slice(breakerIndex)];
  489. return new Promise(resolve => {
  490. const cmd = fork(nodeArgs[0], nodeArgs.slice(1), {
  491. stdio: "inherit",
  492. env: process.env
  493. });
  494. cmd.on("close", code => {
  495. debug(`faux-node done.`);
  496. resolve(code);
  497. });
  498. });
  499. }
  500. function ImitateFirebaseTools(binPath) {
  501. debug("Detected no special flags, calling firebase-tools");
  502. return new Promise(resolve => {
  503. const cmd = fork(binPath, process.argv.slice(2), {
  504. stdio: "inherit",
  505. env: { ...process.env, FIREPIT_VERSION: version }
  506. });
  507. cmd.on("close", code => {
  508. debug(`firebase-tools is done.`);
  509. resolve(code);
  510. });
  511. });
  512. }
  513. /*
  514. -------------------------------------
  515. Core Functions
  516. -------------------------------------
  517. */
  518. async function createRuntimeBinaries() {
  519. /*
  520. As discussed in the introduction, Firepit isn't *just* firebase-tools, it's also npm and node.
  521. We need it to act as several CLI tools in order to support firebase-tools because it shells out
  522. to these other commands in some situations.
  523. In order to support this we add a few special scripts onto the users's path so when a user (or
  524. script) invokes "npm" or "node" it redirects back into Firepit so we can control the environment
  525. regardless of how that command was invoked.
  526. To do this cross-platform, we need to create both shell and batch scripts (for nix / windows).
  527. These scripts are kept very minimal, as you can see in runtimeBins, they're mostly one line or
  528. two.
  529. Each of the platform-specific scripts like "shell" or "node.bat" do the absolute minimum work
  530. needed to act as an executable binary, then immediately redirect the arguments passed to it
  531. back into Firepit via the "shell.js" or "node.js" scripts. (See runtime.js for contents). These
  532. two scripts do the majority of heavy lifting in terms of imitating npm or node.
  533. Originally, we implemented the node / npm stand-ins in pure bash or batch, however there was
  534. way too much platform specific code, by redirecting us back into Firepit (and Node) we add
  535. another process, but we also dramatically reduce per-platform code. The Node code is
  536. cross-platform and works perfectly everywhere. It's also easier to test because any *nix
  537. machine can functionally test the same code that would run on Windows or vice-versa.
  538. */
  539. const runtimeBins = {
  540. /* Linux / OSX */
  541. shell: `"${unsafeNodePath}" ${runtimeBinsPath}/shell.js "$@"`,
  542. node: `"${unsafeNodePath}" ${runtimeBinsPath}/node.js "$@"`,
  543. npm: `"${unsafeNodePath}" "${
  544. FindTool("npm/bin/npm-cli")[0]
  545. }" ${npmArgs.join(" ")} "$@"`,
  546. /* Windows */
  547. "node.bat": `@echo off
  548. "${unsafeNodePath}" ${runtimeBinsPath}\\node.js %*`,
  549. "shell.bat": `@echo off
  550. "${unsafeNodePath}" ${runtimeBinsPath}\\shell.js %*`,
  551. "npm.bat": `@echo off
  552. node "${FindTool("npm/bin/npm-cli")[0]}" ${npmArgs.join(" ")} %*`,
  553. /* Runtime scripts */
  554. "shell.js": `${appendToPath.toString()}\n${getSafeCrossPlatformPath.toString()}\n(${runtime.Script_ShellJS.toString()})()`,
  555. "node.js": `(${runtime.Script_NodeJS.toString()})()`,
  556. /* Config files */
  557. npmrc: `prefix = ${installPath}`
  558. };
  559. /*
  560. We handle creating the runtimeBins files by looping through and writing files. There's nothing
  561. special or interesting here.
  562. */
  563. try {
  564. shell.mkdir("-p", runtimeBinsPath);
  565. } catch (err) {
  566. debug(err);
  567. }
  568. if (!flags["disable-write"]) {
  569. Object.keys(runtimeBins).forEach(filename => {
  570. const runtimeBinPath = path.join(runtimeBinsPath, filename);
  571. try {
  572. shell.rm("-rf", runtimeBinPath);
  573. } catch (err) {
  574. debug(err);
  575. }
  576. fs.writeFileSync(runtimeBinPath, runtimeBins[filename]);
  577. shell.chmod("+x", runtimeBinPath);
  578. });
  579. }
  580. debug("Runtime binaries created.");
  581. }
  582. async function SetupFirebaseTools() {
  583. /*
  584. Firepit supports "setting up" (that is, installing) firebase-tools in two ways.
  585. 1) Use the copy of firebase-tools which is stored inside the firepit binary at
  586. join(__dirname, "vendor/node_modules/firebase-tools")
  587. 2) Use a copy of firebase-tools installed via npm via the internet.
  588. */
  589. debug(`Attempting to install to "${installPath}"`);
  590. const original_argv = [...process.argv];
  591. const nodeModulesPath = path.join(installPath, "lib");
  592. const binPath = path.join(installPath, "bin");
  593. debug(shell.mkdir("-p", nodeModulesPath).toString());
  594. debug(shell.mkdir("-p", binPath).toString());
  595. /*
  596. In general, we use the embedded version of firebase-tools. Once installed, this version can be
  597. upgraded via npm, however it's important to skip npm for the initial setup as it's dramatically
  598. faster.
  599. */
  600. if (!flags["ignore-embedded-cache"]) {
  601. /*
  602. When doing the embedded install, the setup is as simple as cp -R'ing the JavaScript files
  603. to the right place then linking the script to a bin folder (see below).
  604. */
  605. debug("Using embedded cache for quick install...");
  606. debug(
  607. shell
  608. .cp("-R", path.join(__dirname, "vendor/*"), nodeModulesPath)
  609. .toString()
  610. );
  611. } else {
  612. /*
  613. When doing a remote install, we ImitateNPM and run a normal npm install. Note that we're
  614. installing both firebase-tools and "npm" because this will upgrade the copy of npm used
  615. by Firepit. Better up-to-date than sorry!
  616. */
  617. debug("Using remote for slow install...");
  618. // Install remotely
  619. process.argv = [
  620. ...process.argv.slice(0, 2),
  621. "is:npm",
  622. "install",
  623. "-g",
  624. "npm",
  625. config.firebase_tools_package
  626. ];
  627. const code = await ImitateNPM();
  628. if (code) {
  629. console.log("Setup from remote host failed due to npm error.");
  630. process.exit(code);
  631. }
  632. }
  633. /*
  634. When installing remotely, npm automatically links the firebase-tools script to a binary folder,
  635. however sometimes this doesn't happen as expected, so we manually call shell.ln (link) to create
  636. a symlink regardless of the install type.
  637. This step ensures that whether the firebase-tools install was created from the remote or
  638. local install that the binary still exists in the same place.
  639. Note we can not simply move firebase.js because it uses imports relative to it's position in
  640. the node_modules tree.
  641. */
  642. debug(
  643. shell
  644. .ln(
  645. "-sf",
  646. path.join(
  647. nodeModulesPath,
  648. "node_modules/firebase-tools/lib/bin/firebase.js"
  649. ),
  650. path.join(binPath, "firebase")
  651. )
  652. .toString()
  653. );
  654. /*
  655. Finally we check to make sure we now have a copy of the "firebase" command which is findable
  656. and then restore the original process.argv before finishing the setup.
  657. */
  658. if (!FindTool("firebase-tools/lib/bin/firebase").length) {
  659. console.warn(`firebase-tools setup failed.`);
  660. process.exit(2);
  661. }
  662. process.argv = original_argv;
  663. }
  664. /*
  665. -------------------------------------
  666. Other / Helper Functions
  667. -------------------------------------
  668. */
  669. function uninstallLegacyFirepit() {
  670. /*
  671. There are two situations where we should trash the Firepit install directory.
  672. 1) We're using an old firepit version where the "cli" folder exists
  673. 2) We're using an old firebase-tools version where the version is different than ours.
  674. */
  675. /*
  676. To detect an old-style Firepit install, we look for the "cli" folder, a folder which has
  677. been renmaed in new Firepit builds.
  678. */
  679. const isLegacyFirepit = !shell.ls(
  680. path.join(homePath, ".cache", "firebase", "cli")
  681. ).code;
  682. /*
  683. To check for mismatched firebase-tools versions, we find the package.json and read the version
  684. manually then compare it to ours.
  685. */
  686. let installedFirebaseToolsPackage = {};
  687. const installedFirebaseToolsPackagePath = path.join(
  688. homePath,
  689. ".cache/firebase/tools/lib/node_modules/firebase-tools/package.json"
  690. );
  691. const firepitFirebaseToolsPackagePath = path.join(
  692. __dirname,
  693. "vendor/node_modules/firebase-tools/package.json"
  694. );
  695. debug(`Doing JSON parses for version checks at ${firepitFirebaseToolsPackagePath}`);
  696. debug(shell.ls(path.join(__dirname, "vendor/node_modules/")));
  697. const firepitFirebaseToolsPackage = JSON.parse(
  698. shell.cat(firepitFirebaseToolsPackagePath)
  699. );
  700. try {
  701. installedFirebaseToolsPackage = JSON.parse(
  702. shell.cat(installedFirebaseToolsPackagePath)
  703. );
  704. } catch (err) {
  705. debug("No existing firebase-tools install found.");
  706. }
  707. debug(
  708. `Installed ft@${installedFirebaseToolsPackage.version ||
  709. "none"} and packaged ft@${firepitFirebaseToolsPackage.version}`
  710. );
  711. const isLegacyFirebaseTools =
  712. installedFirebaseToolsPackage.version !==
  713. firepitFirebaseToolsPackage.version;
  714. /*
  715. If either of these conditions are true, we just delete the whole cache and start over fresh.
  716. */
  717. if (!isLegacyFirepit && !isLegacyFirebaseTools) return;
  718. debug("Legacy firepit / firebase-tools detected, clearing it out...");
  719. debug(shell.rm("-rf", path.join(homePath, ".cache", "firebase")));
  720. }
  721. async function getFirebaseToolsCommand() {
  722. /*
  723. This helper function produces an absolute, cross-platform "firebase" command reference.
  724. It outputs either "c:\path\to\firebase.exe" or "c:\path\to\firebase.exe path\to\firebase.js"
  725. As discussed above, whether running the firepit binary results in a Node.js runtime or the
  726. "firebase" command can change (seemingly randomly, but it's not) depending on if we're
  727. inside of an existing pkg process. Doing this check ensures that we get a command which
  728. when ran results in "firebase" being ran regardless of environment.
  729. */
  730. const isRuntime = await VerifyNodePath(safeNodePath);
  731. debug(`Node path ${safeNodePath} is runtime? ${isRuntime}`);
  732. let firebase_command;
  733. if (isRuntime) {
  734. const script_path = await getSafeCrossPlatformPath(
  735. isWindows,
  736. path.join(__dirname, "/firepit.js")
  737. );
  738. //TODO: We should store this as an array to prevent issues with spaces
  739. firebase_command = `${safeNodePath} ${script_path}`;
  740. } else {
  741. firebase_command = safeNodePath;
  742. }
  743. debug(firebase_command);
  744. return firebase_command;
  745. }
  746. async function VerifyNodePath(nodePath) {
  747. /*
  748. VerifyNodePath invokes the firepit binary with two flags...
  749. ./firepit check.js --tool:runtime-check
  750. This allows us to determine if the current environment is internal to pkg or not. When it's
  751. internal, meaning that the invocation of firepit is a direct child of another firepit process
  752. then ./firepit will invoke the node runtime which is bundled within the firepit binary.
  753. When it's not internal, it will run the firepit scripts.
  754. This check works because with these flags ./firepit call will run check.js and return a
  755. checkmark if it's acting as the Node runtime and if it's not it will just log something
  756. else and exit.
  757. We use this to ensure that we can always build a command which invokes the Firebase CLI
  758. regardless of where the process is actually being spawned.
  759. */
  760. const runtimeCheckPath = await getSafeCrossPlatformPath(
  761. isWindows,
  762. path.join(__dirname, "check.js")
  763. );
  764. return new Promise(resolve => {
  765. const cmd = spawn(nodePath, [runtimeCheckPath, "--tool:runtime-check"], {
  766. shell: true
  767. });
  768. let result = "";
  769. cmd.on("error", error => {
  770. throw error;
  771. });
  772. cmd.stderr.on("data", stderr => {
  773. debug(`STDERR: ${stderr.toString()}`);
  774. });
  775. cmd.stdout.on("data", stdout => {
  776. debug(`STDOUT: ${stdout.toString()}`);
  777. result += stdout.toString();
  778. });
  779. cmd.on("close", code => {
  780. debug(
  781. `[VerifyNodePath] Expected "✓" from runtime got code ${code} with output "${result}"`
  782. );
  783. if (code === 0) {
  784. if (result.indexOf("✓") >= 0) {
  785. resolve(true);
  786. } else {
  787. resolve(false);
  788. }
  789. } else {
  790. resolve(false);
  791. }
  792. });
  793. });
  794. }
  795. function FindTool(bin) {
  796. /*
  797. This method returns a list of files which match the script name provided. We use this to
  798. locate npm, firebase-tools, etc.
  799. */
  800. const potentialPaths = [
  801. path.join(installPath, "lib/node_modules", bin),
  802. path.join(installPath, "node_modules", bin),
  803. path.join(__dirname, "node_modules", bin)
  804. ];
  805. return potentialPaths
  806. .map(path => {
  807. debug(`Checking for ${bin} install at ${path}`);
  808. if (shell.ls(path + ".js").code === 0) {
  809. debug(`Found ${bin} install.`);
  810. return path;
  811. }
  812. })
  813. .filter(p => p);
  814. }
  815. function SetWindowTitle(title) {
  816. /*
  817. This method *attempts* to set the terminal window title to something pretty so it doesn't
  818. show the internal shell'ing we do. It kinda works, but fails silently, so I've left it in.
  819. */
  820. if (isWindows) {
  821. process.title = title;
  822. }
  823. }
  824. /*
  825. -------------------------------------
  826. Shared Functions
  827. -------------------------------------
  828. These methods are very special and should be edited carefully. They must be pure JavaScript
  829. functions which do not rely on any global state or imports.
  830. If you look at createRuntimeBinaries() and see the runtimeBins scripts, you'll see that we
  831. call getSafeCrossPlatformPath.toString() and appendToPath.toString() and put them into the
  832. scripts which we place on the filesystem. We do this because the scripts in ./runtime.js
  833. depend on these functions and since we need to create single JavaScript files to drop onto
  834. the user's filesystem, we concat them together.
  835. This is fairly dangerous, but we don't have many options.
  836. */
  837. async function getSafeCrossPlatformPath(isWin, path) {
  838. /*
  839. This function generates "safe" DOS style file paths on Windows.
  840. For example:
  841. unsafePath: C:\Program Files\Java\jdk1.6.0_22
  842. safePath: C:\PROGRA~1\Java\JDK16~1.0_2
  843. These paths remove spaces and special characters which could interfere with the terminal.
  844. In theory, it should be possible to avoid this, but because of issues in npm, we need to be
  845. extra safe about spaces.
  846. */
  847. if (!isWin) return path;
  848. /*
  849. This is perhaps the biggest hack in Firepit, we shell out to command and run a small script
  850. which returns the DOS-formatted version of a path. This is not fast, but it's (apparently)
  851. the only way to fetch the safe version of a path
  852. */
  853. let command = `for %I in ("${path}") do echo %~sI`;
  854. return new Promise(resolve => {
  855. const cmd = require("child_process").spawn(`cmd`, ["/c", command], {
  856. shell: true
  857. });
  858. let result = "";
  859. cmd.on("error", error => {
  860. throw error;
  861. });
  862. cmd.stdout.on("data", stdout => {
  863. result += stdout.toString();
  864. });
  865. cmd.on("close", code => {
  866. if (code === 0) {
  867. const lines = result.split("\r\n").filter(line => line);
  868. const path = lines.slice(-1)[0];
  869. resolve(path.trim());
  870. } else {
  871. throw `Attempt to dosify path failed with code ${code}`;
  872. }
  873. });
  874. });
  875. }
  876. function appendToPath(isWin, pathsToAppend) {
  877. /*
  878. This method handles appending a folder to the user's PATH directory in a cross-platform way.
  879. Windows uses ";" to delimit paths and *nix uses ":"
  880. */
  881. const PATH = process.env.PATH;
  882. const pathSeperator = isWin ? ";" : ":";
  883. process.env.PATH = [
  884. ...pathsToAppend,
  885. ...PATH.split(pathSeperator).filter(folder => folder)
  886. ].join(pathSeperator);
  887. }
  888. function debug(...msg) {
  889. /*
  890. This method creates a debug log which can go to stdout or a file depending on --tool: flags.
  891. */
  892. if (!debug.log) debug.log = [];
  893. if (flags["log-debug"]) {
  894. msg.forEach(m => console.log(m));
  895. } else {
  896. msg.forEach(m => debug.log.push(m));
  897. }
  898. }