Skip to content

feat: migrate standalone CLI builds to Node SEAs & Node 24#10784

Draft
joehan wants to merge 1 commit into
mainfrom
jh-seas
Draft

feat: migrate standalone CLI builds to Node SEAs & Node 24#10784
joehan wants to merge 1 commit into
mainfrom
jh-seas

Conversation

@joehan

@joehan joehan commented Jul 9, 2026

Copy link
Copy Markdown
Member

Description

Migrates standalone packaging pipeline from the deprecated pkg utility to native Node Single Executable Applications (SEAs) and upgrades to Node v24.18.0 (LTS).

  • Switched build logic to package CLI assets in a tarball and inject the resource blob into platform-specific Node 24 binaries.
  • Updated the CLI entry point with a custom filesystem resolver to resolve CJS paths under the SEA virtual environment.
  • Configured dynamic script routing to handle child process forking and direct CLI execution natively.

Scenarios Tested

Verified the compiled macos-arm64 binary:

  • Setup extracts NPM modules, binaries, and welcome assets into the cache folder correctly.
  • Forking works as expected and successfully prints the CLI help menu.

Sample Commands

/private/var/folders/cs/.../firebase-tools-macos-arm64 --help

### Description
Migrates standalone packaging pipeline from the deprecated `pkg` utility to native Node Single Executable Applications (SEAs) and upgrades to Node v24.18.0 (LTS).
- Switched build logic to package CLI assets in a tarball and inject the resource blob into platform-specific Node 24 binaries.
- Updated the CLI entry point with a custom filesystem resolver to resolve CJS paths under the SEA virtual environment.
- Configured dynamic script routing to handle child process forking and direct CLI execution natively.

### Scenarios Tested
Verified the compiled macos-arm64 binary:
- Setup extracts NPM modules, binaries, and welcome assets into the cache folder correctly.
- Forking works as expected and successfully prints the CLI help menu.

### Sample Commands
`/private/var/folders/cs/.../firebase-tools-macos-arm64 --help`
@wiz-9635d3485b

wiz-9635d3485b Bot commented Jul 9, 2026

Copy link
Copy Markdown

Wiz Scan Summary

Scanner Findings
Vulnerability Finding Vulnerabilities -
Data Finding Sensitive Data -
Secret Finding Secrets -
IaC Misconfiguration IaC Misconfigurations -
SAST Finding SAST Findings 1 Medium 4 Low
Software Management Finding Software Management Findings -
Total 1 Medium 4 Low

View scan details in Wiz

To detect these findings earlier in the dev lifecycle, try using Wiz Code VS Code Extension.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request replaces the legacy packaging tool pkg with Node's native Single Executable Application (SEA) framework to build standalone binaries for macOS, Linux, and Windows. Key changes include introducing a new build script (build-sea.js) that bundles assets and injects them using postject, and refactoring firepit.js to run in SEA mode with reduced reliance on shelljs. The review feedback identifies several critical issues: improper promise error handling in downloadFile and GET_SAFE_PATH_SRV, potential extraction failures of Windows .zip archives on Linux build hosts, unnecessary bundling of prettier in the assets archive, and a potential crash on older Node.js versions when requiring node:sea without a fallback.

Comment thread standalone/build-sea.js
Comment on lines +42 to +65
function downloadFile(url, dest) {
return new Promise((resolve, reject) => {
function get(url) {
https.get(url, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
get(response.headers.location);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`Failed to download ${url}: status code ${response.statusCode}`));
return;
}
const file = fs.createWriteStream(dest);
response.pipe(file);
file.on("finish", () => {
file.close(resolve);
});
}).on("error", (err) => {
fs.unlink(dest, () => reject(err));
});
}
get(url);
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The downloadFile function has several critical error handling and promise resolution issues:

  1. Incorrect Promise Resolution on Error: file.close(resolve) passes the err argument of the close callback directly to resolve. If an error occurs during closing, the promise will resolve with the error object instead of rejecting.
  2. Unhandled Stream Errors: There is no error handler on the file write stream or the response stream. If a write error occurs (e.g., disk full or permission issues), it will throw an unhandled exception and crash the process.
  3. Memory Leak on Non-200 Status: If the response status code is not 200, the response stream is not consumed/resumed, which can lead to memory leaks.
function downloadFile(url, dest) {
  return new Promise((resolve, reject) => {
    function get(url) {
      const request = https.get(url, (response) => {
        if (response.statusCode === 301 || response.statusCode === 302) {
          get(response.headers.location);
          return;
        }
        if (response.statusCode !== 200) {
          response.resume();
          reject(new Error(`Failed to download ${url}: status code ${response.statusCode}`));
          return;
        }
        const file = fs.createWriteStream(dest);
        response.pipe(file);
        
        file.on("finish", () => {
          file.close((err) => {
            if (err) {
              reject(err);
            } else {
              resolve();
            }
          });
        });

        file.on("error", (err) => {
          fs.unlink(dest, () => reject(err));
        });

        response.on("error", (err) => {
          file.destroy();
          fs.unlink(dest, () => reject(err));
        });
      });

      request.on("error", (err) => {
        fs.unlink(dest, () => reject(err));
      });
    }
    get(url);
  });
}

Comment thread standalone/build-sea.js
Comment on lines +119 to +122
console.log(`Extracting ${archiveName}...`);
const extractDir = path.join(tempDownloads, `extract-${target.name}`);
shell.mkdir("-p", extractDir);
execSync(`tar -xf "${dest}" -C "${extractDir}"`, { stdio: "inherit" });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The Windows target is downloaded as a .zip file. On Linux hosts (which are standard for CI/CD runners), the default GNU tar utility does not support extracting .zip files natively, causing the build script to fail with a tar: This does not look like a tar archive error.

We should conditionally use unzip on non-Windows platforms when extracting .zip files.

    console.log(`Extracting ${archiveName}...`);
    const extractDir = path.join(tempDownloads, `extract-${target.name}`);
    shell.mkdir("-p", extractDir);
    if (target.ext === "zip") {
      if (process.platform === "win32") {
        execSync(`tar -xf "${dest}" -C "${extractDir}"`, { stdio: "inherit" });
      } else {
        execSync(`unzip -q "${dest}" -d "${extractDir}"`, { stdio: "inherit" });
      }
    } else {
      execSync(`tar -xf "${dest}" -C "${extractDir}"`, { stdio: "inherit" });
    }

Comment thread standalone/firepit.js
Comment on lines +313 to +337
const GET_SAFE_PATH_SRV = `async function getSafeCrossPlatformPath(isWin, path) {
if (!isWin) return 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}\`;
}
});
});
}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Inside the GET_SAFE_PATH_SRV template string, throwing errors inside asynchronous event handlers (cmd.on('error') and cmd.on('close')) will result in unhandled exceptions and crash the process instead of rejecting the promise. Additionally, the Promise executor does not declare or use the reject parameter. We should update the promise to properly reject on errors.

const GET_SAFE_PATH_SRV = `async function getSafeCrossPlatformPath(isWin, path) {
  if (!isWin) return path;
  let command = \`for %I in ("\${path}") do echo %~sI\`;
  return new Promise((resolve, reject) => {
    const cmd = require("child_process").spawn(\`cmd\`, ["/c", command], {
      shell: true
    });
    let result = "";
    cmd.on("error", error => {
      reject(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 {
        reject(new Error(\`Attempt to dosify path failed with code \${code}\`));
      }
    });
  });
}`;

Comment thread standalone/build-sea.js
Comment on lines +85 to +88
// Clean up build/dev dependencies from assets
shell.rm("-rf", `${assetsDir}/node_modules/esbuild`);
shell.rm("-rf", `${assetsDir}/node_modules/postject`);
shell.rm("-rf", `${assetsDir}/node_modules/.bin`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

prettier is listed as a devDependency in standalone/package.json but is not removed from node_modules before archiving the assets. This unnecessarily bundles prettier into the firepit-assets.tar.gz archive, increasing the size of the final standalone binary. We should clean it up along with esbuild and postject.

Suggested change
// Clean up build/dev dependencies from assets
shell.rm("-rf", `${assetsDir}/node_modules/esbuild`);
shell.rm("-rf", `${assetsDir}/node_modules/postject`);
shell.rm("-rf", `${assetsDir}/node_modules/.bin`);
// Clean up build/dev dependencies from assets
shell.rm("-rf", `${assetsDir}/node_modules/esbuild`);
shell.rm("-rf", `${assetsDir}/node_modules/postject`);
shell.rm("-rf", `${assetsDir}/node_modules/prettier`);
shell.rm("-rf", `${assetsDir}/node_modules/.bin`);

Comment thread standalone/firepit.js
Comment on lines +183 to +184
const sea = require("node:sea");
const isSeaMode = typeof sea.isSea === "function" && sea.isSea();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Requiring node:sea directly at the top level without a try-catch block will crash the script with a MODULE_NOT_FOUND error on older Node.js versions (< 18.16.0) during local development.

Additionally, isSeaMode is referenced inside getChalk() and getShell() which are declared much earlier in the file. While deferred execution makes this technically valid, it is cleaner and safer to initialize isSeaMode defensively at the top of the file.

let sea;
try {
  sea = require("node:sea");
} catch (err) {
  // node:sea is not supported in this Node.js version
}
const isSeaMode = !!(sea && typeof sea.isSea === "function" && sea.isSea());

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants