1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075 |
- /*
- -------------------------------------
- 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));
- }
- }
|