Conversation
### 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 Scan Summary
To detect these findings earlier in the dev lifecycle, try using Wiz Code VS Code Extension. |
There was a problem hiding this comment.
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.
| 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); | ||
| }); | ||
| } |
There was a problem hiding this comment.
The downloadFile function has several critical error handling and promise resolution issues:
- Incorrect Promise Resolution on Error:
file.close(resolve)passes theerrargument of the close callback directly toresolve. If an error occurs during closing, the promise will resolve with the error object instead of rejecting. - Unhandled Stream Errors: There is no error handler on the
filewrite stream or theresponsestream. If a write error occurs (e.g., disk full or permission issues), it will throw an unhandled exception and crash the process. - 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);
});
}| console.log(`Extracting ${archiveName}...`); | ||
| const extractDir = path.join(tempDownloads, `extract-${target.name}`); | ||
| shell.mkdir("-p", extractDir); | ||
| execSync(`tar -xf "${dest}" -C "${extractDir}"`, { stdio: "inherit" }); |
There was a problem hiding this comment.
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" });
}| 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}\`; | ||
| } | ||
| }); | ||
| }); | ||
| }`; |
There was a problem hiding this comment.
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}\`));
}
});
});
}`;| // 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`); |
There was a problem hiding this comment.
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.
| // 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`); |
| const sea = require("node:sea"); | ||
| const isSeaMode = typeof sea.isSea === "function" && sea.isSea(); |
There was a problem hiding this comment.
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());
Description
Migrates standalone packaging pipeline from the deprecated
pkgutility to native Node Single Executable Applications (SEAs) and upgrades to Node v24.18.0 (LTS).Scenarios Tested
Verified the compiled macos-arm64 binary:
Sample Commands
/private/var/folders/cs/.../firebase-tools-macos-arm64 --help