Using NPM Run-Scripts To Execute Shell Commands In Lucee CFML 5.3.6.61
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
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
-cliworkingDir
flag CommandBox supports to let you override its default working directory. So CFExecute would run the equivalent ofAnd, of course, your
box.json
would be along the lines ofJust 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:
#now
package bump
!ls
(Ironically, using Process Builder :) )repl 'http url="http:www.google.com/";'
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 doand 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 :)
@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
andstart
andstop
: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).
You're in luck man, because there's a 2 day workshop happening this month!
https://www.ortussolutions.com/events