DEV Community

Ernesto Bellei for hund

Posted on

How close are we to being able to create CLI executables with Node.js?

In recent days, I had the opportunity to revisit and reorganize the tools we use to set up our development environments. When we start a new development project, we always need two things:

  • A web server (usually Nginx);
  • A proxy to quickly share local or test instances both publicly and internally.

In addition to those two, many other secondary tools are currently all managed through a Node.js CLI tool. Hence my concern: before even being able to run the configuration tools, there is the need to install Node.js.

"And what if I wanted to get rid of this additional initial step as well?" — No one

In the past, I had already experimented with pkg (now archived), so I decided to dedicate a day to updating myself on the topic and improving our tools.

Step 1: Creating the bundle

The first step towards creating an executable is to generate a bundle of the CLI tool. I've delved into the tool I know best: Webpack.
Given that build efficiency and speed are not variables of our problem, Webpack allows me, with a couple of loaders, to quickly create a single entry point:

File webpack.config.js

...
{
    test: /\.tsx?$/,
    use: "ts-loader",
    exclude: /node_modules/,
},
{
    test: /\.(node)$/,
    loader: "node-loader",
},
...
Enter fullscreen mode Exit fullscreen mode

In a few minutes, the bundle task is solved; we add a script to the package.json, and we are ready to proceed.

File package.json

"scripts" : {
...
    "bundle": "webpack",
...
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Creating a .blob file

The next step, now that we have our cli.js bundle in the dist folder, is to generate a .blob file that we will use to create our executable file. To do this, the first step is to define within a configuration file:

  • the entry point of our .blob (i.e., the bundle just created);
  • the output where the .blob file will be generate.

File seaconfig.json

{
    "main": "./dist/cli.js",
    "output": "./dist/cli.blob",
}
Enter fullscreen mode Exit fullscreen mode

Once the configuration file is ready, the next step is to create our executable... here all the credit goes to Chad R. Stewart for saving me a lot of work. Here's a link to his article where he goes into more detail for each platform.

I will report here only the steps I had to execute on my Ubuntu machine:

  • Generate the .blob file:
node --experimental-sea-config seaconfig.json
Enter fullscreen mode Exit fullscreen mode

Prepare the Node.js executable into which we will inject our file in order to prepare the final file.

Note: Currently, the version of Node.js I'm working with is 21.7.1.

cp $(command -v node) ./dist/server-tools
Enter fullscreen mode Exit fullscreen mode
  • Finally, we inject the .blob file into our Node.js executable to complete the task (Depending on the platform, refer to the article linked above):
npx postject ./dist/server-tools NODE_SEA_BLOB ./dist/cli.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2
Enter fullscreen mode Exit fullscreen mode

At this point, I was ecstatic to have obtained a perfectly functioning executable of my CLI tool... until...

Step 3: Managing Static Assets

Testing it, I realized that, being a configuration tool, it used various static files as configuration templates, and those static files were not included inside my executable.

Fortunately, while researching how to work around this problem, I came across two useful resources:

With just a few modifications to the configuration file, I included the necessary files in the executable:

File seaconfig.json

{
    "main": "./dist/cli.js",
    "output": "./dist/cli.blob",
    "assets": {
        "default.txt": "./templates/default.txt",
        "default--ssl.txt": "./templates/default--ssl.txt",
        "next-js.txt": "./templates/next-js.txt",
        "next-js--ssl.txt": "./templates/next-js--ssl.txt",
        "patched.txt": "./templates/patched.txt"
    }
}
Enter fullscreen mode Exit fullscreen mode

At this point, the last remaining step was to fix the code (in a very raw and quick way) from:

File addTemplate.ts

import { readFileSync } from "fs";
...
const content = readFileSync(resolve(__dirname, "templates", `${template}${ssl ? "--ssl" : ""}.txt`);
Enter fullscreen mode Exit fullscreen mode

to:

File addTemplate.ts

import { getAsset } from "node:sea";
import { readFileSync } from "fs";
...
const content = (() => {
    try {
        return readFileSync( resolve(__dirname, "templates", `${template}${ssl ? "--ssl" : ""}.txt`), "utf-8" );
    } catch (error) {
        return getAsset(`${template}${ssl ? "--ssl" : ""}.txt`, "utf-8");
    }
})();
Enter fullscreen mode Exit fullscreen mode

Only note: I also had to add a declaration to ensure that TypeScript didn't complain about the missing module:

File types.d.ts

declare module 'node:sea' {
    export function getAsset(filename: string, encoding: string): string;
}
Enter fullscreen mode Exit fullscreen mode

Final thoughts

By the end of the day, I successfully achieved the desired outcome: My executable functions flawlessly on a pristine VPS without the need to install Node.js in advance.

Top comments (0)