Skip to main content
Ben Nadel at the Angular NYC Meetup (Sep. 2018) with: Akshay Nihalaney
Ben Nadel at the Angular NYC Meetup (Sep. 2018) with: Akshay Nihalaney ( @ANihalaney )

Using NPM Run-Scripts To Execute Shell Commands In Lucee CFML 5.3.6.61

By on
Tags:

Over the last few months, I've looked at various ways that ColdFusion can interact with the command-line. For example, we can use a proxy script to run CFExecute from a working directory; or, we can use Java's ProcessBuilder to interact with the underlying processes directly. The other day, fellow InVisioneer - Shawn Grigson - offered up yet another interesting approach: using npm run scripts. I use the npm command-line tool all the time in development; however, it never would have occurred to me to use it from within my ColdFusion application. As such, I wanted to look at how that might work in Lucee CFML 5.3.6.61.

The CFExecute tag in ColdFusion is useful. But, it is limited in what it can do. npm run-scripts, on the other hand, are very flexible and open-ended, even allowing us to chain multiple scripts together. To be honest, I'm not an npm expert. In fact, I probably know less about npm than I do about shells scripts (which is saying something). As such, please take this exploration with a grain of salt.

That said, let's look at how we might execute an npm run-script from within ColdFusion. Which is to say, how do we execute the npm CLI tool; and, how do we tell it which package.json file it should be using?

As it turns out, there is a --prefix argument which will force the npm CLI tool to execute the subsequent commands from the given directory. We can therefore use this --prefix argument to tell npm to run scripts based on an arbitrary package.json file.

In the following demo, I'm going to programmatically write a package.json file that contains a "scripts" facet. Then, I'm going to use the CFExecute tag to invoke the global npm binary against the given package.json file:

<cfscript>

	// To keep the demo fully encapsulated, let's dynamically generate the package.json
	// file that we're about to consume via npm run-script.
	```
	<cfsavecontent variable="json">
		{
			"scripts": {
				"meow": "echo meooooowwww",
				"kitty": "cat",
				"ls": "ls ."
			}
		}
	</cfsavecontent>
	```

	// Write the package.json content to disk.
	packagePath = expandPath( "./package.json" );
	fileWrite( packagePath, json.trim().reReplace( "(?m)^\t{2}", "", "all" ) );

	// ------------------------------------------------------------------------------- //
	// ------------------------------------------------------------------------------- //

	// Let's test the MOEW run script.
	result = npmRunScript(
		packagePath,
		"meow",
		[ "prrrrrrr", "prr", "prrrr" ]
	);
	echo( "<pre>#encodeForHtml( result )#</pre>" );
	echo( "- -" );

	// Let's test the KITTY run script (and read-in the package.json that we just wrote).
	result = npmRunScript(
		packagePath,
		"kitty",
		[ packagePath ]
	);
	echo( "<pre>#encodeForHtml( result )#</pre>" );
	echo( "- -" );

	// Let's test the LS run script to see which directory it's executing in.
	result = npmRunScript(
		packagePath,
		"ls"
	);
	echo( "<pre>#encodeForHtml( result )#</pre>" );


	// ------------------------------------------------------------------------------- //
	// ------------------------------------------------------------------------------- //

	/**
	* I use CFExecute to run the given script the npm's run-script execution. The result
	* of the output is returned. If any error output is produced, an error is thrown.
	* 
	* @packageJsonPath I am the path to the package.json file containing the scripts.
	* @scriptName I am the name of the script to execute.
	* @scriptArguments I am OPTIONAL arguments to pass to the script execution.
	* @timeout I am the timeout to use for the script execution.
	* @terminateOnTimeout I determine if the script should terminate on timeout.
	*/
	public string function npmRunScript(
		required string packageJsonPath,
		required string scriptName,
		array scriptArguments = [],
		numeric timeout = 5,
		boolean terminateOnTimeout = true
		) {

		// We don't really need the package.json file itself - we really just want the
		// directory in which it is defined. That sad, in order to prevent unexpected
		// behavior due to automatic path traversal, let's make sure that the
		// package.json file really does exists at the given path. This way, we can be
		// confident that npm won't walk the file-tree looking for a package.json file.
		if ( ! fileExists( packageJsonPath ) ) {

			throw(
				type = "NpmRunScript.PackageNotFound",
				message = "package.json file not found.",
				detail = "Path: #packageJsonPath#"
			);

		}

		var npmArguments = [
			// Tell npm from which directory it should execute the command.
			"--prefix #getDirectoryFromPath( packageJsonPath )#",
			"run",
			scriptName
		];

		// If the calling context provided additional commands, they need to come after
		// a "--" delimiter so that npm knows how to differentiate the npm arguments from
		// the script arguments.
		if ( scriptArguments.len() ) {

			npmArguments.append( "--" );
			npmArguments.append( scriptArguments, true );

		}

		execute
			name = "npm"
			arguments = npmArguments.toList( " " )
			variable = "local.successOutput"
			errorVariable = "local.errorOutput"
			timeout = timeout
			terminateOnTimeout = terminateOnTimeout
		;

		// If npm produced any error output, throw an error.
		if ( ( errorOutput ?: "" ).len() ) {

			throw(
				type = "NpmRunScript.ErrorOutput",
				message = "npm run script resulting in error output.",
				detail = "Error output: #errorOutput#",
				extendedInfo = "Arguments: #serializeJson( arguments )#"
			);

		}

		// BASED ON TRIAL-AND-ERROR, it seems that the first few lines of success output
		// are meta-data about the command. As such, in order to get the output from the
		// underlying command, let's strip-off the first few lines.
		var linebreak = chr( 10 );
		var sanitizedOutput = successOutput
			.listToArray( linebreak )
			.deleteAt( 1 ) // The script name and location.
			.deleteAt( 1 ) // The script value.
			.toList( linebreak )
		;

		return( sanitizedOutput );

	}

</cfscript>

As you can see, our generated package.json file has three run-scripts in it:

  • meow: echo meooooowwww
  • kitty: cat
  • ls: ls ."

The final script, ls, is there just to confirm the working directory that the npm script is running in. And, when we run this ColdFusion code, we get the following output:

meooooowwww prrrrrrr prr prrrr

- -

{
	"scripts": {
		"meow": "echo meooooowwww",
		"kitty": "cat",
		"ls": "ls ."
	}
}

- -

package.json
test.cfm

As you can see, were able to run the echo, cat, and ls commands via CFExecute through the npm run-script mechanism. And, as we can see from the last ls output, the npm run command is executing from the directory in which package.json file was located.

This is an interesting technique! I don't know if I would use this over ProcessBuilder or the vanilla CFExecute tag. But, I could definitely see it being useful when you have existing, robust functionality in your package.json file that you want to leverage. It's also cool to see how nicely Lucee CFML can play with other technologies like npm.

Want to use code from this post? Check out the license.

Reader Comments

45 Comments

Hi Ben, I was going to tell you to just use CommandBox's run-script command, which pretty much works like npm's but is just way cooler.

https://commandbox.ortusbooks.com/package-management/package-scripts

The only missing piece is CommandBox's run-script doesn't have a prefix arg. I've always just assumed people would run package commands from... the root of their package. I suppose I could easy add that tho. Unfortunately in your case, you're specifically using npm --prefix because cfexecute* doesn't let you pass a working directory. I think I'd use process builder over npm any day of the week. In addition to having all the dependencies right inside the JVM, it would be another excuse not to ever touch Node :)

Actually, come to think of it-- you can use CommandBox's run-script and just work around the working directory with the top level -cliworkingDirflag CommandBox supports to let you override its default working directory. So CFExecute would run the equivalent of

box -cliworkingDir=C:/my/path/here/ run-script meow

And, of course, your box.json would be along the lines of

{
	"scripts": {
		"meow": "echo meooooowwww",
		"kitty": "!cat",
		"ls": "!ls ."
	}
}

Just remember, CommandBox's run-scripts are way cooler because they're not limited to just native executions, but any valid command that you could run from the box interactive shell including:

  • CFML Functions like #now
  • Custom commands like package bump
  • Native commands like !ls (Ironically, using Process Builder :) )
  • Ad-hoc CFML in the repl like repl 'http url="http:www.google.com/";'
  • Task Runners: task run /scripts/build.cfc publish

I guess in thinking about this, the ceremony of even using a package script would be largely unnecessary in CommandBox. If you just wanted to run native binary xyz then you might as well just do

box -cliworkingDir=C:/my/path/here/ "!xyz"

and skip the package script entirely.
Anyway, I said all that to say this-- I look forward to reading your post on your experiment with CommandBox's package scripts and native execution :)

15,666 Comments

@Brad,

Oh man, every time you guys talk about CommandBox on the Modernize or Die podcast, I feel shame :D I literally only know box and start and stop :D I need to sign-up for one of your "CommandBox Zero to Hero" virtual workshops. I've read through the docs (a long time ago); but, it kind of does in one eye and out the other unless I sit down any really play with it.

Of course, I'm using CommandBox daily for R&D but we don't actually have CB in production (since our system is very old).

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel