/* ------------------------------------- Introduction ------------------------------------- "This is probably the scariest 1000 lines of code I have ever seen" - Sam Stern Welcome to Firepit! This script (and it's siblings) is a bundle of magical code which allow the firebase-tools package to run on a developer's machine without a dependency on Node.js as a single, standalone binary. If firebase-tools was a simpler tool, Firepit would also be simpler, however... it's not. The "firebase" command relies on a few patterns which make bundling it without Node.js particularly difficult, specifically it enjoys shelling out to npm / node. Most of the work in this package is to properly ensure that those commands (npm, node) exist and function as expected even when deep in multiple layers of shelling. Some examples of how shelling is used... 1) Running any "firebase" command will automatically call npm to check is the "firebase-tools" package itself is outdated. 2) Running "firebase deploy --only functions" uses npm to build and prepare the developer's Cloud Functions code. 3) Developer's Cloud Functions may require being built with Typescript or other tools which require access to Node / npm The majority of firebase-tools commands work perfectly with minimal effort from Firepit, specifically any JavaScript-only commands (which are most) work totally fine. Most of the complexity is related to building and deploying Cloud Functions. Firepit's job isn't *just* to ensure all commands work, it also simplifies the getting started flows for developers by offering a "hand-holding" setup (see welcome.js) and improving what we call the "double-click" experience (when a developer downloads the file and clicks it to run). Beyond that Firepit also puts extra effort into ensuring that *any* "firebase" related command will still function if copy/pasted from existing tutorials. Specifically, if the internet says running "npm update -g firebase-tools" will update your CLI, then the internet must be right and we need to support that. This code is generally very carefully written with special care given to cross platform compatibility. We avoid many cross-platform problems by getting *back* into Node as soon as possible. We'll talk more about this below, but most code which helps Firepit work cross-platform is not platform-specific code, but in fact uses Node's natural cross-platform tools / libraries to help out as much as possible. We'll discuss this more in detail below. Ready? Let's go! */ /* ------------------------------------- Globals ------------------------------------- Our dependencies are largely uninteresting, we use "user-home" to know where to install our scripts and files to, we use "chalk" for nice colors, and we use a handful of built in libraries for their intended purposes. The most interesting dep is "shelljs". This library is a collection of Unix-style commands like (cat, ls, mkdir, etc) which are reimplemented in cross-platform JavaScript. They function identically across platforms and help us whenever we're dealing with the filesystem. The names are universal and easy to understand for anyone with a *nix background. We also include our own package.json so we can report the Firepit version to Google Analytics. */ const fs = require("fs"); const path = require("path"); const { fork, spawn } = require("child_process"); const homePath = require("user-home"); const chalk = require("chalk"); const shell = require("shelljs"); shell.config.silent = true; const version = require("./package.json").version; /* Our only other require, the "./runtime.js" file, is worth discussing in detail. The script itself is documented in itself, so you're welcome to read that, however the more important topic is the general structure Firepit uses. Firepit loops back into itself constantly and is essentially a router which ensures that incoming invocations end up calling the correct scripts using the embedded Node runtime. A Firepit binary doesn't include *just* the "firebase" command, it also includes "npm" and "node" because these are needed by "firebase-tools" to be fully functional. When running in headful (double-click) mode these commands are exposed to the developer, they can run "npm" just like they would with a normal Node install, however internally it's not *really* npm, they're invoking a shell script which comes back into a new Firepit process and is then routed to the npm scripts. When you're not running Firepit in headful mode, these sub-commands can still be accessed via hidden flags... firebase is:npm install -g chalk // Calls npm firebase is:node ./script.js // Calls node firebase --help // Calls firebase-tools These hidden flags aren't intended to be used by end-developers, they're needed because we're constantly hoping out of the Firepit process. For example Firepit spawn a shell, the shell calls "npm" (which is actually a new Firepit process) which calls the npm scripts which invokes a user's build script which spawns a node process (which is actually a new Firepit process) and so on. We use these special flags to give context between invocations and ask Firepit to imitate whatever tool the user wants to call. (See Imitate*() functions) In order to allow ensure that the "node", "npm", and "firebase" commands exist through all these processes we can do two things. 1) We can modify env variables like PATH to place our scripts in place of actual tools 2) We can pass special flags to the tools we're pretending to be so they tell their children that the world is how we want them to think it is. Technically (and on a high level) When a developer runs Firepit we go through a series of steps. 1) If needed, extract the copy of "firebase-tools" which is embedded in the binary file (see SetupFirebaseTools()) 2) Generate a series of "runtime" scripts which get called from other processes. These scripts look to the developer like the "npm" or "node" commands, but actually route back into Firepit and are redirected to the embedded tools. (see createRuntimeBinaries()) 3) Determine how we can access our embedded NodeJS runtime (see VerifyNodePath()) 4) Modify the developers env variables to include the "runtime" scripts and other changes (see firepit()) 5) Route the invocation to the correct command (firebase, npm, or node). 6) Exit with the correct code and go to bed. The "runtime.js" script contains two functions. In createRuntimeBinaries() we call .toString() on these functions and write them to files (which later act like commands on the user's path). The functions in "runtime.js" are not meant to be invoked from Firepit, but are standalone scripts which get ran *through* Firepit when it is imitating Node.js. */ const runtime = require("./runtime"); /* We use a configuration file (see config.template.js) which is generated by our build pipeline to determine if we're running in headless or headful mode. */ let config; try { config = require("./config"); } catch (err) { console.warn("Invalid Firepit configuration, this may be a broken build."); process.exit(2); } const isWindows = process.platform === "win32"; /* The installPath is where we'll place our extracted firebase-tools scripts. The runtimeBinsPath is where we place our "npm" and "node" shell scripts which route back into Firepit. */ const installPath = path.join(homePath, ".cache", "firebase", "tools"); let runtimeBinsPath = path.join(homePath, ".cache", "firebase", "runtime"); /* As I mentioned above, one of the ways we can control the detached children processes which get created when using Firepit is to pass special arguments when we're pretending to be them. In this case, when a user calls "npm" (and it routes to Firepit, pretending to be npm) we tack on a few scripts which change the global config file to point to our custom installPath and we supply a special "script shell". This "script shell" is normally something like "bash" or "cmd.exe", however in our case, we want to inject Firepit into there again to ensure everyone thinks the commands we're exposing still exist. You can see the implementation of this script in runtime.js/Script_ShellJS(). When npm invokes a script on behalf of the developer (like when they run "npm run build") this command is then spawned in npm as "$SCRIPT_SHELL $USER_SCRIPT" so by replacing the this shell we can set up env variables / PATHs / etc then spawn the $USER_SCRIPT manually so the behavior looks no different. We use these base npmArgs every time we pretend to be npm. They can be overwritten by a user if they manually specify any of these flags and that would produce unexpected behavior. */ const npmArgs = [ `--script-shell=${runtimeBinsPath}/shell${isWindows ? ".bat" : ""}`, `--globalconfig=${path.join(runtimeBinsPath, "npmrc")}`, `--userconfig=${path.join(runtimeBinsPath, "npmrc")}`, `--scripts-prepend-node-path=auto` ]; /* Windows is terrible and through-out Firepit you'll see references to "safe" and "unsafe" paths. Unsafe paths, on Windows, are ones with things like spaces in them - yes spaces break stuff. There is debate about who is at fault. It may be npm, it may be Node, it may be Microsoft, regardless if your username on Windows (for example) has a space in it, it'll break everything. Luckily because we control the universe in Firepit, we can use a crazy hack to replace any evil (i.e. space-inclusive) paths with DOS (yes DOS) style paths. See getSafeCrossPlatformPath() For example: unsafePath: C:\Program Files\Java\jdk1.6.0_22 safePath: C:\PROGRA~1\Java\JDK16~1.0_2 We use the safePath when needed (specifically when passing them through cmd.exe) to reduce the chances of space-related bugs. This is needed *all* the time, but it's pretty common in here. */ let safeNodePath; const unsafeNodePath = process.argv[0]; /* Firepit supports some additional flags that the firebase command does not. These flags are generally used internally when Firepit invokes Firepit (for example, during welcome.js). If you want to run any of these flags, invoke Firepit with --tool:$COMMAND */ const flagDefinitions = [ "file-debug", // --tool:file-debug - Write log to a file "log-debug", // --tool:log-debug - Write log to stdout "disable-write", // --tool:disable-write - Do not write runtime scripts to filesystem "runtime-check", // --tool:runtime-check - Determine if firepit binary is node or not (see VerifyNodePath()) "setup-check", // --tool:setup-check - Check if firebase-tools is set up "force-setup", // --tool:force-setup - Force Firepit to go through setup "force-update", // --tool:force-update - Aggressively clear npm cache and re-setup "ignore-embedded-cache" // --tool:ignore-embedded-cache - Setup from online, do not use embedded firebase-tools ]; /* This script parses our flagDefinitions and returns a map like {file-debug: false, ...} */ const flags = flagDefinitions.reduce((flags, name) => { flags[name] = process.argv.indexOf(`--tool:${name}`) !== -1; if (flags[name]) { process.argv.splice(process.argv.indexOf(`--tool:${name}`), 1); } return flags; }, {}); /* We use @zeit/pkg to actually bundle our JavaScript with the NodeJS runtime to produce our binaries. In general if you're running your code inside of pkg and you attempt to spawn the pkg binary which you invoked to run your code (i.e. firepit.exe invokes firepit.exe) what you'll actually be invoking is the underlying Node.js binary which is embedded is the binary. This works well, albeit it may be a bit unexpected, however due to the nature of Firepit, there's no assurance that we'll actually be in the same process at any given time. For example, if we invoke "./firepit" and Firepit spawns a shell and that shell is used to call "firebase" we're now in a situation where invoking "./firepit" from "firebase" will act as a fresh call to Firepit, resulting it in running through the setup and such. In another example, if we invoke "./firepit" and it immediately spawns "./firepit" then it'll be spawning a node process. I know this is confusing, but the moral is that we can be sure at any moment if spawning "./firepit" will provide us with this file running in Node or just a Node runtime. To detect what the "firepit" binary is we run "./firepit check.js --tool:runtime-check" in VerifyNodePath(). If "./firepit" is acting as Firepit, this conditional will flip and we'll just exit out. If "./firepit" is acting as a Node runtime, it'll invoke check.js and return a unicode ✓. This allows us to know if we can safely invoke Node scripts by calling ourselves or if we must call "./firepit is:node ./script" to force it to manually imitate Node. */ if (flags["runtime-check"]) { console.log(`firepit invoked for runtime check, exiting subpit.`); return; } debug(`Welcome to firepit v${version}!`); /* ------------------------------------- The Main Path ------------------------------------- When running Firepit, we start here. This async closure handles checking most of the --tool flags and ensuring that Firepit is setup and in-place before running firepit() */ (async () => { /* Any time we invoke a child process from Firepit, we tack on a FIREPIT_VERSION env variable. This is useful here so we can detect if we are the "top level" Firepit instance. For example, if you are running Firepit in headful mode then the first instance of Firepit spawns you a command prompt window. In that command prompt we go through welcome.js then you're given access to the "firebase" command. When you run "firebase", if we didn't know if we were top-level then you'd just spawn another command prompt window - clearly not what we want. So we look for the env variable set by the process which spawned the window. If it exists, we functionally fall into "headless" mode and act like a normal Firebase CLI. */ const isTopLevel = !process.env.FIREPIT_VERSION; /* As I mentioned above, we make heavy use of this function to DOS-isy paths to avoid space-issues. In this case, we're using process.argv[0] (always a reference to the node binary which spawned this script) and turning it safe so we have an invokable Node.js runtime for later. */ safeNodePath = await getSafeCrossPlatformPath(isWindows, process.argv[0]); /* If the user has ever had an older version of Firepit, clear it out and replace it with us. */ uninstallLegacyFirepit(); /* --tool:setup-check is used by welcome.js and returns out a JSON list of binaries for the "firebase" command. It's essentially a check to see if we can find a copy of "firebase" to invoke. The FindTool function looks in several places for where it thinks our firebase script might be and returns as many as it fins. We almost always use the 0th one. */ if (flags["setup-check"]) { const bins = FindTool("firebase-tools/lib/bin/firebase"); for (const bin of bins) { bins[bin] = await getSafeCrossPlatformPath(bins[bin]); } console.log(JSON.stringify({ bins })); return; } /* --tool:force-update is never used internally, but can be useful for EAPs where version numbers may be incorrect. This manually clear NPMs cache and then flips the flags "ignore-embedded-cache" and "force-setup" to tell Firepit to install itself from the remote package (either a link to a tgz or just firebase-tools@latest). */ if (flags["force-update"]) { console.log(`Please wait while we clear npm's cache...`); /* This is the first instance of invoking one of the Imitate*() methods. These methods are the methods which "route" to the underlying scripts for each command. As you'd expect ImitateNPM forces the process to act just like npm. By replacing the process.argv before calling ImitateNPM(), we're rewriting what the command was which called Firepit. For example, this snippet creates the following command... /blah/blah/node ./firepit.js is:npm cache clean --force As far as Firepit is concerned, this looks just like invoking it with is:npm from the top. It may be cleaner to have Imitate*() take an array of command strings instead of modifying process.argv, but for now I'll leave it like this. */ process.argv = [ ...process.argv.slice(0, 2), "is:npm", "cache", "clean", "--force" ]; /* The Imitate*() methods also always return codes (0, 1, 2) from the underlying script. We need to make sure we bubble these up because incorrect handling of exit codes will create unexpected behavior in scripts. */ const code = await ImitateNPM(); if (code) { console.log("NPM cache clearing failed, can't update."); process.exit(code); } flags["ignore-embedded-cache"] = true; flags["force-setup"] = true; console.log(`Clearing out your firebase-tools setup...`); /* Here's a handy use of shelljs. It's stupidly hard to recursively remove a directory with Node's standard libs. Shelljs makes it trivial. */ shell.rm("-rf", installPath); } /* Every time Firepit is invoked it recreates the runtime binaries (node, npm, shell) because these binaries need to know the current location of the Firepit binary. See the function comments for more. */ await createRuntimeBinaries(); /* If we're in --tool:force-setup then extract or remotely install firebase-tools then exit out. */ if (flags["force-setup"]) { debug("Forcing setup..."); await SetupFirebaseTools(); console.log("firebase-tools setup complete."); return; } /* As I mentioned above, isTopLevel is basically the same as headless mode. There's an entire flow here which revolves around invoking "./welcome.js" See that script for more details */ if (isTopLevel && !config.headless) { const welcome_path = await getSafeCrossPlatformPath( isWindows, path.join(__dirname, "/welcome.js") ); const firebaseToolsCommand = await getFirebaseToolsCommand(); /* This function adds a directory onto the PATH env variable. On Windows they're ; separated and *nix they're : seperated. */ appendToPath(isWindows, [path.join(installPath, "bin"), runtimeBinsPath]); /* As I mentioned above, we set the FIREPIT_VERSION env variable so that the shell we spawn doesn't spawn another window and it instead acts as a headless firepit. */ const shellEnv = { FIREPIT_VERSION: version, ...process.env }; if (isWindows) { /* This is some of the only platform specific bits we have here. On Windows, headful mode spawns a custom cmd.exe prompt with doskey (alias) commands called to expose the "firebase" and "npm" commands. We also set the prompt to a neat yellow ">" then invoke the welcome script. This top level Firepit script sits open until the developer closes that terminal. */ const shellConfig = { stdio: "inherit", env: shellEnv }; spawn( "cmd", [ "/k", [ `doskey firebase=${firebaseToolsCommand} $*`, `doskey npm=${firebaseToolsCommand} is:npm $*`, `set prompt=${chalk.yellow("$G")}`, `${firebaseToolsCommand} is:node ${welcome_path} ${firebaseToolsCommand}` ].join(" & ") ], shellConfig ); process.on("SIGINT", () => { debug("Received SIGINT. Refusing to close top-level shell."); }); } else { /* If we're not on Windows, then we can technically perform headful mode on Mac. By default double-clicking a binary on Mac will pop up a terminal, so we just invoke the welcome screen and set the bash prompt. */ process.argv = [ ...process.argv.slice(0, 2), "is:node", welcome_path, firebaseToolsCommand ]; const code = await ImitateNode(); if (code) { console.log("Node failed to run welcome script."); process.exit(code); } spawn("bash", { env: { ...shellEnv, PS1: "\\e[0;33m> \\e[m" }, stdio: "inherit" }); } } else { /* In the case that Firepit is not in headful mode (or it was loaded in headful more, but is not the top level process), then we jump into the actual firepit() method which takes care of routing the is:npm, is:node, or other core modes. */ SetWindowTitle("Firebase CLI"); await firepit(); } if (flags["file-debug"]) { fs.writeFileSync("firepit-log.txt", debug.log.join("\n")); } })().catch(err => { /* Note we have a high-level catch here which attempts to catch any crazy firepit errors. This is rarely hit, but it will produce a firepit-log.txt when some internal errors occur. */ debug(err.toString()); console.log( `This tool has encountered an error. Please file a bug on Github (https://github.com/firebase/firebase-tools/) and include firepit-log.txt` ); fs.writeFileSync("firepit-log.txt", debug.log.join("\n")); }); async function firepit() { /* When running inside Node, the "node" binary is stored in many places. As I mentioned earlier, it's the 0th item of process.argv and it's also in a couple other places. To be safe we get a "safe" version of the Node runtime path and replace all known references with this. */ runtimeBinsPath = await getSafeCrossPlatformPath(isWindows, runtimeBinsPath); // TODO: I'm not sure this is needed, more testing would be useful. process.argv[0] = safeNodePath; process.env.NODE = safeNodePath; process.env._ = safeNodePath; debug(safeNodePath); debug(process.argv); // TODO: This may not be needed since we invoke createRuntimeBinaries() earlier await createRuntimeBinaries(); appendToPath(isWindows, [runtimeBinsPath]); /* We check for the is:npm and is:node flags and if either exist, we opt ot imitate that process and then exit out when done. */ if (process.argv.indexOf("is:npm") !== -1) { const code = await ImitateNPM(); process.exit(code); } if (process.argv.indexOf("is:node") !== -1) { const code = await ImitateNode(); process.exit(code); } /* If Firepit was invoked in headless mode, there is a chance that firebase-tools has not been set up yet (since the welcome screen was never shown and that script is what calls --tool:forces-setup. To be sure, we attempt to find the firebase-tools script and if it's not found, we attempt a setup. After the setup, if the script still isn't found then something is wrong and we die. */ let firebaseBins = FindTool("firebase-tools/lib/bin/firebase"); if (!firebaseBins.length) { debug(`CLI not found! Invoking setup...`); await SetupFirebaseTools(); firebaseBins = FindTool("firebase-tools/lib/bin/firebase"); } /* Assuming we've gotten this far, we've found the CLI and we're ready to run firebase-tools. That was easy, huh? */ const firebaseBin = firebaseBins[0]; debug(`CLI install found at "${firebaseBin}", starting fork...`); const code = await ImitateFirebaseTools(firebaseBin); process.exit(code); } /* ------------------------------------- Imitate*() ------------------------------------- All of the Imitate*() methods are very similar. For is:npm and is:node we break process.argv based on that string and then pass everything on the right to the script, which is forked from the main Node process. We create a promise (which can be awaited) and then resolve when the command is done. */ function ImitateNPM() { debug("Detected is:npm flag, calling NPM"); const breakerIndex = process.argv.indexOf("is:npm") + 1; const args = [...npmArgs, ...process.argv.slice(breakerIndex)]; debug(args.join(" ")); return new Promise(resolve => { const cmd = fork(FindTool("npm/bin/npm-cli")[0], args, { stdio: "inherit", env: process.env }); cmd.on("close", code => { debug(`faux-npm done.`); resolve(code); }); }); } function ImitateNode() { debug("Detected is:node flag, calling node"); const breakerIndex = process.argv.indexOf("is:node") + 1; const nodeArgs = [...process.argv.slice(breakerIndex)]; return new Promise(resolve => { const cmd = fork(nodeArgs[0], nodeArgs.slice(1), { stdio: "inherit", env: process.env }); cmd.on("close", code => { debug(`faux-node done.`); resolve(code); }); }); } function ImitateFirebaseTools(binPath) { debug("Detected no special flags, calling firebase-tools"); return new Promise(resolve => { const cmd = fork(binPath, process.argv.slice(2), { stdio: "inherit", env: { ...process.env, FIREPIT_VERSION: version } }); cmd.on("close", code => { debug(`firebase-tools is done.`); resolve(code); }); }); } /* ------------------------------------- Core Functions ------------------------------------- */ async function createRuntimeBinaries() { /* As discussed in the introduction, Firepit isn't *just* firebase-tools, it's also npm and node. We need it to act as several CLI tools in order to support firebase-tools because it shells out to these other commands in some situations. In order to support this we add a few special scripts onto the users's path so when a user (or script) invokes "npm" or "node" it redirects back into Firepit so we can control the environment regardless of how that command was invoked. To do this cross-platform, we need to create both shell and batch scripts (for nix / windows). These scripts are kept very minimal, as you can see in runtimeBins, they're mostly one line or two. Each of the platform-specific scripts like "shell" or "node.bat" do the absolute minimum work needed to act as an executable binary, then immediately redirect the arguments passed to it back into Firepit via the "shell.js" or "node.js" scripts. (See runtime.js for contents). These two scripts do the majority of heavy lifting in terms of imitating npm or node. Originally, we implemented the node / npm stand-ins in pure bash or batch, however there was way too much platform specific code, by redirecting us back into Firepit (and Node) we add another process, but we also dramatically reduce per-platform code. The Node code is cross-platform and works perfectly everywhere. It's also easier to test because any *nix machine can functionally test the same code that would run on Windows or vice-versa. */ const runtimeBins = { /* Linux / OSX */ shell: `"${unsafeNodePath}" ${runtimeBinsPath}/shell.js "$@"`, node: `"${unsafeNodePath}" ${runtimeBinsPath}/node.js "$@"`, npm: `"${unsafeNodePath}" "${ FindTool("npm/bin/npm-cli")[0] }" ${npmArgs.join(" ")} "$@"`, /* Windows */ "node.bat": `@echo off "${unsafeNodePath}" ${runtimeBinsPath}\\node.js %*`, "shell.bat": `@echo off "${unsafeNodePath}" ${runtimeBinsPath}\\shell.js %*`, "npm.bat": `@echo off node "${FindTool("npm/bin/npm-cli")[0]}" ${npmArgs.join(" ")} %*`, /* Runtime scripts */ "shell.js": `${appendToPath.toString()}\n${getSafeCrossPlatformPath.toString()}\n(${runtime.Script_ShellJS.toString()})()`, "node.js": `(${runtime.Script_NodeJS.toString()})()`, /* Config files */ npmrc: `prefix = ${installPath}` }; /* We handle creating the runtimeBins files by looping through and writing files. There's nothing special or interesting here. */ try { shell.mkdir("-p", runtimeBinsPath); } catch (err) { debug(err); } if (!flags["disable-write"]) { Object.keys(runtimeBins).forEach(filename => { const runtimeBinPath = path.join(runtimeBinsPath, filename); try { shell.rm("-rf", runtimeBinPath); } catch (err) { debug(err); } fs.writeFileSync(runtimeBinPath, runtimeBins[filename]); shell.chmod("+x", runtimeBinPath); }); } debug("Runtime binaries created."); } async function SetupFirebaseTools() { /* Firepit supports "setting up" (that is, installing) firebase-tools in two ways. 1) Use the copy of firebase-tools which is stored inside the firepit binary at join(__dirname, "vendor/node_modules/firebase-tools") 2) Use a copy of firebase-tools installed via npm via the internet. */ debug(`Attempting to install to "${installPath}"`); const original_argv = [...process.argv]; const nodeModulesPath = path.join(installPath, "lib"); const binPath = path.join(installPath, "bin"); debug(shell.mkdir("-p", nodeModulesPath).toString()); debug(shell.mkdir("-p", binPath).toString()); /* In general, we use the embedded version of firebase-tools. Once installed, this version can be upgraded via npm, however it's important to skip npm for the initial setup as it's dramatically faster. */ if (!flags["ignore-embedded-cache"]) { /* When doing the embedded install, the setup is as simple as cp -R'ing the JavaScript files to the right place then linking the script to a bin folder (see below). */ debug("Using embedded cache for quick install..."); debug( shell .cp("-R", path.join(__dirname, "vendor/*"), nodeModulesPath) .toString() ); } else { /* When doing a remote install, we ImitateNPM and run a normal npm install. Note that we're installing both firebase-tools and "npm" because this will upgrade the copy of npm used by Firepit. Better up-to-date than sorry! */ debug("Using remote for slow install..."); // Install remotely process.argv = [ ...process.argv.slice(0, 2), "is:npm", "install", "-g", "npm", config.firebase_tools_package ]; const code = await ImitateNPM(); if (code) { console.log("Setup from remote host failed due to npm error."); process.exit(code); } } /* When installing remotely, npm automatically links the firebase-tools script to a binary folder, however sometimes this doesn't happen as expected, so we manually call shell.ln (link) to create a symlink regardless of the install type. This step ensures that whether the firebase-tools install was created from the remote or local install that the binary still exists in the same place. Note we can not simply move firebase.js because it uses imports relative to it's position in the node_modules tree. */ debug( shell .ln( "-sf", path.join( nodeModulesPath, "node_modules/firebase-tools/lib/bin/firebase.js" ), path.join(binPath, "firebase") ) .toString() ); /* Finally we check to make sure we now have a copy of the "firebase" command which is findable and then restore the original process.argv before finishing the setup. */ if (!FindTool("firebase-tools/lib/bin/firebase").length) { console.warn(`firebase-tools setup failed.`); process.exit(2); } process.argv = original_argv; } /* ------------------------------------- Other / Helper Functions ------------------------------------- */ function uninstallLegacyFirepit() { /* There are two situations where we should trash the Firepit install directory. 1) We're using an old firepit version where the "cli" folder exists 2) We're using an old firebase-tools version where the version is different than ours. */ /* To detect an old-style Firepit install, we look for the "cli" folder, a folder which has been renmaed in new Firepit builds. */ const isLegacyFirepit = !shell.ls( path.join(homePath, ".cache", "firebase", "cli") ).code; /* To check for mismatched firebase-tools versions, we find the package.json and read the version manually then compare it to ours. */ let installedFirebaseToolsPackage = {}; const installedFirebaseToolsPackagePath = path.join( homePath, ".cache/firebase/tools/lib/node_modules/firebase-tools/package.json" ); const firepitFirebaseToolsPackagePath = path.join( __dirname, "vendor/node_modules/firebase-tools/package.json" ); debug(`Doing JSON parses for version checks at ${firepitFirebaseToolsPackagePath}`); debug(shell.ls(path.join(__dirname, "vendor/node_modules/"))); const firepitFirebaseToolsPackage = JSON.parse( shell.cat(firepitFirebaseToolsPackagePath) ); try { installedFirebaseToolsPackage = JSON.parse( shell.cat(installedFirebaseToolsPackagePath) ); } catch (err) { debug("No existing firebase-tools install found."); } debug( `Installed ft@${installedFirebaseToolsPackage.version || "none"} and packaged ft@${firepitFirebaseToolsPackage.version}` ); const isLegacyFirebaseTools = installedFirebaseToolsPackage.version !== firepitFirebaseToolsPackage.version; /* If either of these conditions are true, we just delete the whole cache and start over fresh. */ if (!isLegacyFirepit && !isLegacyFirebaseTools) return; debug("Legacy firepit / firebase-tools detected, clearing it out..."); debug(shell.rm("-rf", path.join(homePath, ".cache", "firebase"))); } async function getFirebaseToolsCommand() { /* This helper function produces an absolute, cross-platform "firebase" command reference. It outputs either "c:\path\to\firebase.exe" or "c:\path\to\firebase.exe path\to\firebase.js" As discussed above, whether running the firepit binary results in a Node.js runtime or the "firebase" command can change (seemingly randomly, but it's not) depending on if we're inside of an existing pkg process. Doing this check ensures that we get a command which when ran results in "firebase" being ran regardless of environment. */ const isRuntime = await VerifyNodePath(safeNodePath); debug(`Node path ${safeNodePath} is runtime? ${isRuntime}`); let firebase_command; if (isRuntime) { const script_path = await getSafeCrossPlatformPath( isWindows, path.join(__dirname, "/firepit.js") ); //TODO: We should store this as an array to prevent issues with spaces firebase_command = `${safeNodePath} ${script_path}`; } else { firebase_command = safeNodePath; } debug(firebase_command); return firebase_command; } async function VerifyNodePath(nodePath) { /* VerifyNodePath invokes the firepit binary with two flags... ./firepit check.js --tool:runtime-check This allows us to determine if the current environment is internal to pkg or not. When it's internal, meaning that the invocation of firepit is a direct child of another firepit process then ./firepit will invoke the node runtime which is bundled within the firepit binary. When it's not internal, it will run the firepit scripts. This check works because with these flags ./firepit call will run check.js and return a checkmark if it's acting as the Node runtime and if it's not it will just log something else and exit. We use this to ensure that we can always build a command which invokes the Firebase CLI regardless of where the process is actually being spawned. */ const runtimeCheckPath = await getSafeCrossPlatformPath( isWindows, path.join(__dirname, "check.js") ); return new Promise(resolve => { const cmd = spawn(nodePath, [runtimeCheckPath, "--tool:runtime-check"], { shell: true }); let result = ""; cmd.on("error", error => { throw error; }); cmd.stderr.on("data", stderr => { debug(`STDERR: ${stderr.toString()}`); }); cmd.stdout.on("data", stdout => { debug(`STDOUT: ${stdout.toString()}`); result += stdout.toString(); }); cmd.on("close", code => { debug( `[VerifyNodePath] Expected "✓" from runtime got code ${code} with output "${result}"` ); if (code === 0) { if (result.indexOf("✓") >= 0) { resolve(true); } else { resolve(false); } } else { resolve(false); } }); }); } function FindTool(bin) { /* This method returns a list of files which match the script name provided. We use this to locate npm, firebase-tools, etc. */ const potentialPaths = [ path.join(installPath, "lib/node_modules", bin), path.join(installPath, "node_modules", bin), path.join(__dirname, "node_modules", bin) ]; return potentialPaths .map(path => { debug(`Checking for ${bin} install at ${path}`); if (shell.ls(path + ".js").code === 0) { debug(`Found ${bin} install.`); return path; } }) .filter(p => p); } function SetWindowTitle(title) { /* This method *attempts* to set the terminal window title to something pretty so it doesn't show the internal shell'ing we do. It kinda works, but fails silently, so I've left it in. */ if (isWindows) { process.title = title; } } /* ------------------------------------- Shared Functions ------------------------------------- These methods are very special and should be edited carefully. They must be pure JavaScript functions which do not rely on any global state or imports. If you look at createRuntimeBinaries() and see the runtimeBins scripts, you'll see that we call getSafeCrossPlatformPath.toString() and appendToPath.toString() and put them into the scripts which we place on the filesystem. We do this because the scripts in ./runtime.js depend on these functions and since we need to create single JavaScript files to drop onto the user's filesystem, we concat them together. This is fairly dangerous, but we don't have many options. */ async function getSafeCrossPlatformPath(isWin, path) { /* This function generates "safe" DOS style file paths on Windows. For example: unsafePath: C:\Program Files\Java\jdk1.6.0_22 safePath: C:\PROGRA~1\Java\JDK16~1.0_2 These paths remove spaces and special characters which could interfere with the terminal. In theory, it should be possible to avoid this, but because of issues in npm, we need to be extra safe about spaces. */ if (!isWin) return path; /* This is perhaps the biggest hack in Firepit, we shell out to command and run a small script which returns the DOS-formatted version of a path. This is not fast, but it's (apparently) the only way to fetch the safe version of a path */ let command = `for %I in ("${path}") do echo %~sI`; return new Promise(resolve => { const cmd = require("child_process").spawn(`cmd`, ["/c", command], { shell: true }); let result = ""; cmd.on("error", error => { throw error; }); cmd.stdout.on("data", stdout => { result += stdout.toString(); }); cmd.on("close", code => { if (code === 0) { const lines = result.split("\r\n").filter(line => line); const path = lines.slice(-1)[0]; resolve(path.trim()); } else { throw `Attempt to dosify path failed with code ${code}`; } }); }); } function appendToPath(isWin, pathsToAppend) { /* This method handles appending a folder to the user's PATH directory in a cross-platform way. Windows uses ";" to delimit paths and *nix uses ":" */ const PATH = process.env.PATH; const pathSeperator = isWin ? ";" : ":"; process.env.PATH = [ ...pathsToAppend, ...PATH.split(pathSeperator).filter(folder => folder) ].join(pathSeperator); } function debug(...msg) { /* This method creates a debug log which can go to stdout or a file depending on --tool: flags. */ if (!debug.log) debug.log = []; if (flags["log-debug"]) { msg.forEach(m => console.log(m)); } else { msg.forEach(m => debug.log.push(m)); } }