qwen-code/scripts/create-standalone-package.js
Shaojin Wen 715ef938f5
feat(cli): serve the Web Shell UI from qwen serve (#5392)
* feat(cli): serve the Web Shell UI from `qwen serve`

`qwen serve` now serves the built Web Shell SPA at its root on the same
origin as the API, so a released binary exposes the browser terminal
without the dev-only Vite server (the `npm run dev:daemon` two-process
setup is unchanged for development).

- New `webShellStatic.ts` mounts `/`, `/assets/*` and an SPA deep-link
  fallback. The fallback uses the same document-navigation discriminator
  as the Vite dev proxy so it never shadows API JSON 404s.
- The static shell is registered BEFORE bearerAuth (a browser can't attach
  a token to a `<script>` subresource or an address-bar navigation; the
  shell carries no secrets and every API route stays token-gated). HTML
  responses set CSP + X-Frame-Options + Referrer-Policy + no-cache.
- `--open` launches the browser at the daemon URL (with `?token=` when set)
  once the listener is up, guarded by `shouldLaunchBrowser()`.
- `--no-web` opts out for an API-only daemon.
- Bundle / npm publish / standalone packaging now ship `dist/web-shell/`.
  Missing assets degrade to API-only with a breadcrumb, never a hard fail.

Tests: +6 cases in server.test.ts (root shell, assets, SPA fallback,
non-navigation 404 passthrough, security headers, --no-web off).

* fix(cli): address review on Web Shell serving

Review fixes for #5392 (qwen-code-ci-bot):

- [Critical] SPA fallback no longer shadows /health or /demo on non-loopback
  binds — those paths fall through to their own routes / bearerAuth instead
  of receiving index.html.
- [Critical] --open trims the bearer token before putting it in the browser
  URL, matching runQwenServe's own trimming, so a trailing newline from
  `$(cat token.txt)` no longer makes every API call 401.
- --open is wrapped in its own try/catch so a failed browser launch can't
  take down the already-listening daemon; it normalizes wildcard binds
  (0.0.0.0 / ::) to loopback, and only fires when the UI is actually mounted
  (new RunHandle.webShellMounted).
- resolveWebShellDir() now requires BOTH index.html and assets/, so a partial
  build degrades to API-only instead of serving a shell whose chunks 404.
- runQwenServe logs a positive "Web Shell UI served from <dir>" breadcrumb,
  and warns that on a non-loopback bind without --allow-origin the shell is
  read-only (same-origin POSTs are blocked by the CORS wall).
- Document the --open token-in-process-list exposure in help text + a stderr
  note when a token is forwarded.
- Tests: POST method guard, sec-fetch navigation signal, /health not shadowed,
  sendFile 500 path, plus isDocumentNavigation and resolveWebShellDir units.

* fix(cli): harden Web Shell asset resolution and send-error logging

Second-round review (claude /qreview on the initial commit):

- resolveWebShellDir() now walks up from this module to find a sibling
  packages/web-shell/dist, covering the transpiled layouts the previous
  fixed `..` depth missed — per-package `tsc` output and the integration
  daemon harness (packages/cli/dist/index.js), which would otherwise resolve
  to nonexistent paths and silently run API-only.
- sendFile failures are no longer silent: log the error (matching the /demo
  handler — previously the only 5xx path that emitted nothing) and res.end()
  a half-streamed response instead of leaving the client on a 200 with a
  partial body.

The remaining comment (open-browser inside the boot try) was already fixed
in 2487c90, where the --open block gained its own try/catch.

* fix(cli): pass --open token via URL fragment + add auth-contract tests

Third-round review (qwen3.7-max /review):

- --open now puts the token in the URL fragment (#token=) instead of a query
  param, and the Web Shell reads it from the fragment first (falling back to
  ?token= for the dev launcher / hand-built URLs). A fragment is never sent to
  the server, so the token stays out of access logs and Referer headers. It is
  still visible in the browser-launcher's argv, so the stderr note stays and a
  one-time-code exchange remains the real fix for multi-user hosts (follow-up).
- Add a server test pinning the "shell served before bearerAuth, API still
  token-gated" contract (GET / → 200 without auth, /capabilities → 401 with a
  token set), plus front-end getDaemonToken fragment/query precedence tests.

The token-trim comment in this pass was already addressed in 2487c90.

* fix(cli): read --open token from RunHandle.resolvedToken; doc + test polish

Fourth-round review (qwen3.7-max /review), all suggestions:

- --open now reads the server's resolved (trimmed) token from
  RunHandle.resolvedToken instead of re-deriving it from argv/env. Removes the
  duplicated QWEN_SERVER_TOKEN literal + trim logic and any drift risk; the
  browser token is by construction what the daemon authenticates against.
- Simplify webShellMounted to !!webShellDir (serveWebShell===false already
  forces webShellDir to undefined, so the extra conjunct was dead).
- Docs: the --open row now documents the #token= fragment transport (was
  ?token=) and why a fragment is used.
- Tests: add removeDaemonTokenFromUrl coverage (strip from fragment / query /
  both, preserve non-token hash params, no-op when absent) and the missing
  afterEach import.

* fix(cli): register Web Shell SPA fallback after API routes

Fifth-round review (claude /qreview):

- The SPA fallback no longer sits before bearerAuth. It now runs after every
  API route (just before the error handler), so authed routes — and their
  401s — always win, and only genuine 404 misses fall through to the shell.
  A navigation with an attacker-controlled `Accept: text/html` to
  /capabilities (or /health on a non-loopback bind) no longer coaxes the 200
  shell out of a gated endpoint, and the fragile exact-match /health,/demo
  denylist (which trailing-slash variants slipped past) is gone.
  registerWebShell is split into mountWebShellAssets (/, /assets — still
  pre-auth so a browser can load the shell + subresources without a header)
  and mountWebShellSpaFallback (post-auth). The contract test now sends
  Accept: text/html to /capabilities and asserts 401 — it would have been 200
  before this change (the test was passing only because it omitted Accept).
- verifyBundleArtifacts (the publish gate) now requires dist/web-shell, so a
  build that skipped the web-shell workspace (e.g. npm ci --ignore-scripts
  bypassing the root prepare) fails packaging loudly instead of silently
  shipping an API-only CLI whose GET / 404s.

* fix(cli): return a clean 404 for missing Web Shell assets

Sixth-round review (qwen3.7-max /review):

A missing /assets/* (e.g. a stale hashed chunk after a redeploy renamed it)
now returns 404 instead of falling through to the SPA fallback and answering a
browser navigation with a 200 index.html. Implemented with an explicit /assets
404 handler after express.static rather than serve-static's `fallthrough:
false` — the latter forwards a 404 error to the catch-all error handler, which
would turn it into a 500. Test added.

* test(cli): cover --open + Web Shell signals; add shell security headers

Seventh-round review (qwen-code-ci-bot):

- [Critical] Extract the --open browser-launch logic into the exported
  maybeOpenWebShellBrowser() and unit-test it: --open / webShellMounted /
  shouldLaunchBrowser gating, wildcard-host -> loopback rewrite, token in the
  URL fragment (not query), and the never-throws error catch.
- [Critical] Assert RunHandle.webShellMounted (false under --no-web) and
  resolvedToken (trimmed / undefined) in runQwenServe.test.ts; also cover
  --web/--no-web and --open arg parsing.
- Drop dead code: target.hostname === '::' is unreachable (Node's URL returns
  the IPv6 wildcard as '[::]', which is already handled).
- Add defense-in-depth headers to the shell response: base-uri 'none' in the
  CSP (does not fall back to default-src), X-Content-Type-Options: nosniff,
  and a restrictive Permissions-Policy.
- Add serve-debug-gated logging for /assets 404s and SPA-fallback hits so a
  white-screen shell / routing misconfig has a diagnostic trail.

* fix(test): satisfy the Web Shell release gate in package-assets fixture

Eighth-round review (claude /qreview) — this is the actual CI failure.

The verifyBundleArtifacts Web Shell gate (requiring dist/web-shell, added in
this PR) broke scripts/tests/package-assets.test.js, which merge-main pulled
in: its createBundleArtifacts fixture only created cli.js / vendor / bundled,
so preparePackage exited 1 at the new gate before the test's assertions ran —
red on all three Test jobs. Add the web-shell artifacts (index.html +
assets/) to the fixture. The gate itself is intentional (it stops an API-only
package from shipping).
2026-06-19 19:41:33 +08:00

622 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { execFileSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
const distDir = path.join(rootDir, 'dist');
const TARGETS = new Map([
[
'darwin-arm64',
{ outputExtension: 'tar.gz', nodeExecutable: ['bin', 'node'] },
],
[
'darwin-x64',
{ outputExtension: 'tar.gz', nodeExecutable: ['bin', 'node'] },
],
[
'linux-arm64',
{ outputExtension: 'tar.gz', nodeExecutable: ['bin', 'node'] },
],
['linux-x64', { outputExtension: 'tar.gz', nodeExecutable: ['bin', 'node'] }],
['win-x64', { outputExtension: 'zip', nodeExecutable: ['node.exe'] }],
]);
const DIST_REQUIRED_PATHS = [
'cli.js',
'chunks',
'vendor',
'bundled/qc-helper/docs',
];
const DIST_ALLOWED_ENTRIES = new Set([
'cli.js',
// bin wrapper emitted by prepare-package.js that re-spawns `node --expose-gc
// cli.js`; ships in dist/ as the package `bin` entry (#4914).
'cli-entry.js',
// fzf fuzzy-search worker; esbuild emits it as a standalone entry that must
// sit next to cli.js so `new URL('./fzfWorker.js', ...)` resolves at runtime.
'fzfWorker.js',
'chunks',
'vendor',
'bundled',
'package.json',
'README.md',
'LICENSE',
'locales',
'examples',
// Web Shell SPA served at the daemon root by `qwen serve` (index.html +
// assets/). Copied into dist/web-shell/ by copy_bundle_assets.js when the
// web-shell workspace has been built; optional, so it's allowed but not
// required.
'web-shell',
]);
const DIST_ALLOWED_ENTRY_PATTERNS = [
/^sandbox-macos-(permissive|restrictive)-(open|closed|proxied)\.sb$/,
];
const ROOT_REQUIRED_PATHS = ['README.md', 'LICENSE'];
if (isMainModule()) {
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printUsage();
return;
}
const target = args.target;
if (!target || !TARGETS.has(target)) {
fail(`--target must be one of: ${Array.from(TARGETS.keys()).join(', ')}`);
}
if (!args.nodeArchive) {
fail('--node-archive is required');
}
const nodeArchive = path.resolve(args.nodeArchive);
if (!fs.existsSync(nodeArchive)) {
fail(`Node.js archive not found: ${nodeArchive}`);
}
assertRequiredInputs();
const version = args.version || readPackageVersion();
const outDir = path.resolve(args.outDir || path.join(distDir, 'standalone'));
fs.mkdirSync(outDir, { recursive: true });
const targetConfig = TARGETS.get(target);
const outputName = `qwen-code-${target}.${targetConfig.outputExtension}`;
const outputPath = path.join(outDir, outputName);
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-standalone-'));
try {
const packageRoot = path.join(tempRoot, 'qwen-code');
const runtimeExtractDir = path.join(tempRoot, 'runtime');
fs.mkdirSync(packageRoot, { recursive: true });
fs.mkdirSync(runtimeExtractDir, { recursive: true });
copyRuntimeAssets(packageRoot, outDir);
extractNodeArchive(nodeArchive, runtimeExtractDir);
const nodeDir = path.join(packageRoot, 'node');
copyExtractedNode(runtimeExtractDir, nodeDir);
validateNodeRuntime(target, nodeDir);
writeShims(packageRoot);
writeManifest(packageRoot, {
version,
target,
nodeArchive: path.basename(nodeArchive),
});
if (fs.existsSync(outputPath)) {
fs.rmSync(outputPath, { force: true });
}
createArchive(targetConfig.outputExtension, outputPath, tempRoot);
if (!args.skipChecksums) {
await writeSha256Sums(outDir);
}
console.log(`Created ${path.relative(rootDir, outputPath)}`);
if (!args.skipChecksums) {
console.log(
`Updated ${path.relative(rootDir, path.join(outDir, 'SHA256SUMS'))}`,
);
}
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
function isMainModule() {
return process.argv[1] && path.resolve(process.argv[1]) === __filename;
}
function parseArgs(argv) {
const args = {
help: false,
outDir: undefined,
nodeArchive: undefined,
skipChecksums: false,
target: undefined,
version: undefined,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
switch (arg) {
case '--help':
case '-h':
args.help = true;
break;
case '--target':
args.target = readOptionValue(argv, index, arg);
index += 1;
break;
case '--node-archive':
args.nodeArchive = readOptionValue(argv, index, arg);
index += 1;
break;
case '--out-dir':
args.outDir = readOptionValue(argv, index, arg);
index += 1;
break;
case '--version':
args.version = readOptionValue(argv, index, arg);
index += 1;
break;
case '--skip-checksums':
args.skipChecksums = true;
break;
default:
fail(`Unknown option: ${arg}`);
}
}
return args;
}
function readOptionValue(argv, index, optionName) {
const value = argv[index + 1];
if (!value || value.startsWith('-')) {
fail(`${optionName} requires a value`);
}
return value;
}
function printUsage() {
console.log(`Qwen Code standalone package builder
Usage:
npm run package:standalone -- --target TARGET --node-archive PATH [OPTIONS]
Options:
--target TARGET One of: ${Array.from(TARGETS.keys()).join(', ')}
--node-archive PATH Downloaded Node.js runtime archive.
--out-dir DIR Output directory. Defaults to dist/standalone.
--version VERSION Qwen Code version. Defaults to package.json version.
--skip-checksums Do not update SHA256SUMS. Used by release packaging.
-h, --help Show this help message.`);
}
function assertRequiredInputs() {
if (!fs.existsSync(distDir)) {
fail('dist/ directory not found. Run "npm run bundle" first.');
}
for (const relativePath of DIST_REQUIRED_PATHS) {
const fullPath = path.join(distDir, relativePath);
if (!fs.existsSync(fullPath)) {
fail(`Required dist asset missing: ${fullPath}`);
}
}
for (const relativePath of ROOT_REQUIRED_PATHS) {
const fullPath = path.join(rootDir, relativePath);
if (!fs.existsSync(fullPath)) {
fail(`Required repository file missing: ${fullPath}`);
}
}
}
function readPackageVersion() {
const packageJsonPath = path.join(rootDir, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
return packageJson.version;
}
function copyRuntimeAssets(packageRoot, outDir) {
const libDir = path.join(packageRoot, 'lib');
const skippedDistEntry = topLevelDistEntryForPath(outDir);
fs.mkdirSync(libDir, { recursive: true });
for (const entry of fs.readdirSync(distDir)) {
if (entry === skippedDistEntry || entry === '.DS_Store') {
continue;
}
if (!isAllowedDistEntry(entry)) {
fail(`Unexpected dist asset: ${path.join(distDir, entry)}`);
}
fs.cpSync(path.join(distDir, entry), path.join(libDir, entry), {
recursive: true,
dereference: true,
verbatimSymlinks: false,
});
}
assertNoSymlinks(libDir, 'Copied runtime assets still contain symlinks.');
for (const fileName of ROOT_REQUIRED_PATHS) {
fs.copyFileSync(
path.join(rootDir, fileName),
path.join(packageRoot, fileName),
);
}
fs.copyFileSync(
path.join(rootDir, 'package.json'),
path.join(packageRoot, 'package.json'),
);
}
function topLevelDistEntryForPath(candidatePath) {
const relative = path.relative(distDir, candidatePath);
if (
relative === '' ||
relative.startsWith('..') ||
path.isAbsolute(relative)
) {
return undefined;
}
return relative.split(path.sep)[0];
}
function isAllowedDistEntry(entry) {
return (
DIST_ALLOWED_ENTRIES.has(entry) ||
DIST_ALLOWED_ENTRY_PATTERNS.some((pattern) => pattern.test(entry))
);
}
function extractNodeArchive(nodeArchive, extractDir) {
if (nodeArchive.endsWith('.zip')) {
extractZipArchive(nodeArchive, extractDir);
return;
}
if (
nodeArchive.endsWith('.tar.gz') ||
nodeArchive.endsWith('.tgz') ||
nodeArchive.endsWith('.tar.xz')
) {
run('tar', ['-xf', nodeArchive, '-C', extractDir]);
return;
}
fail(
`Unsupported Node.js archive format: ${nodeArchive}. Expected .zip, .tar.gz, .tgz, or .tar.xz.`,
);
}
function extractZipArchive(nodeArchive, extractDir) {
if (process.platform === 'win32') {
run(
'powershell',
[
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-Command',
'Expand-Archive -LiteralPath $env:QWEN_NODE_ARCHIVE -DestinationPath $env:QWEN_EXTRACT_DIR -Force',
],
{
env: {
...process.env,
QWEN_NODE_ARCHIVE: nodeArchive,
QWEN_EXTRACT_DIR: extractDir,
},
},
);
return;
}
run('unzip', ['-q', nodeArchive, '-d', extractDir]);
}
function copyExtractedNode(extractDir, nodeDir) {
const entries = fs
.readdirSync(extractDir)
.filter((entry) => entry !== '.DS_Store');
if (entries.length === 0) {
fail('Node.js archive did not contain any files.');
}
const sourceRoot =
entries.length === 1 &&
fs.statSync(path.join(extractDir, entries[0])).isDirectory()
? path.join(extractDir, entries[0])
: extractDir;
// Official Unix Node.js archives include internal npm/npx symlinks.
// The installer rejects symlinks in final archives, so keep safe internal
// targets by copying their referents during a single checked traversal.
copyNodeRuntimeEntry(sourceRoot, nodeDir, {
realRoot: fs.realpathSync(sourceRoot),
sourceRoot,
activeDirectories: new Set(),
});
}
function copyNodeRuntimeEntry(source, destination, state) {
const lstat = fs.lstatSync(source);
if (lstat.isSymbolicLink()) {
copyNodeRuntimeEntry(
resolveRuntimeSymlink(source, state),
destination,
state,
);
return;
}
if (lstat.isDirectory()) {
const realSource = fs.realpathSync(source);
if (state.activeDirectories.has(realSource)) {
fail(
`Node.js runtime contains a symlink cycle at ${displayRuntimePath(
state,
source,
)}`,
);
}
state.activeDirectories.add(realSource);
fs.mkdirSync(destination, { recursive: true });
fs.chmodSync(destination, lstat.mode);
for (const entry of fs.readdirSync(source)) {
copyNodeRuntimeEntry(
path.join(source, entry),
path.join(destination, entry),
state,
);
}
state.activeDirectories.delete(realSource);
return;
}
if (lstat.isFile()) {
fs.copyFileSync(source, destination);
fs.chmodSync(destination, lstat.mode);
return;
}
fail(`Unsupported Node.js runtime entry type: ${source}`);
}
function resolveRuntimeSymlink(source, state) {
const target = fs.readlinkSync(source);
const resolvedTarget = path.resolve(path.dirname(source), target);
let realTarget;
try {
realTarget = fs.realpathSync(resolvedTarget);
} catch (error) {
const errorCode =
error && typeof error === 'object' && 'code' in error
? error.code
: undefined;
const reason =
errorCode === 'ELOOP' ? 'a symlink cycle' : 'a missing target';
fail(
`Node.js runtime symlink points to ${reason}: ${displayRuntimePath(
state,
source,
)} -> ${target}`,
);
}
if (!isPathInside(state.realRoot, realTarget)) {
fail(
`Node.js runtime symlink escapes the archive: ${displayRuntimePath(
state,
source,
)} -> ${target}`,
);
}
return resolvedTarget;
}
function displayRuntimePath(state, source) {
return path.relative(state.sourceRoot, source) || '.';
}
function assertNoSymlinks(root, message) {
for (const entry of walkDirectory(root)) {
if (fs.lstatSync(entry).isSymbolicLink()) {
fail(`${message} First symlink: ${path.relative(root, entry)}`);
}
}
}
function* walkDirectory(root) {
for (const entry of fs.readdirSync(root)) {
const fullPath = path.join(root, entry);
yield fullPath;
if (fs.lstatSync(fullPath).isDirectory()) {
yield* walkDirectory(fullPath);
}
}
}
function isPathInside(root, candidate) {
const relative = path.relative(root, candidate);
return (
relative === '' ||
(!relative.startsWith('..') && !path.isAbsolute(relative))
);
}
function validateNodeRuntime(target, nodeDir) {
const targetConfig = TARGETS.get(target);
const executablePath = path.join(nodeDir, ...targetConfig.nodeExecutable);
const displayPath = targetConfig.nodeExecutable.join('/');
if (!fs.existsSync(executablePath)) {
fail(`Node.js runtime for ${target} must contain ${displayPath}.`);
}
if (target !== 'win-x64') {
const mode = fs.statSync(executablePath).mode;
if ((mode & 0o111) === 0) {
fail(
`Node.js runtime for ${target} must provide executable ${displayPath}.`,
);
}
}
}
function writeShims(packageRoot) {
const binDir = path.join(packageRoot, 'bin');
fs.mkdirSync(binDir, { recursive: true });
const unixShim = `#!/usr/bin/env sh
set -e
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
exec "$ROOT/node/bin/node" --expose-gc "$ROOT/lib/cli.js" "$@"
`;
const unixShimPath = path.join(binDir, 'qwen');
fs.writeFileSync(unixShimPath, unixShim);
fs.chmodSync(unixShimPath, 0o755);
const windowsShim = `@echo off
setlocal
set "ROOT=%~dp0.."
"%ROOT%\\node\\node.exe" --expose-gc "%ROOT%\\lib\\cli.js" %*
`;
fs.writeFileSync(path.join(binDir, 'qwen.cmd'), windowsShim);
}
function writeManifest(packageRoot, manifest) {
const manifestPath = path.join(packageRoot, 'manifest.json');
fs.writeFileSync(
manifestPath,
JSON.stringify(
{
name: '@qwen-code/qwen-code',
version: manifest.version,
target: manifest.target,
nodeArchive: manifest.nodeArchive,
createdAt: new Date().toISOString(),
},
null,
2,
) + '\n',
);
}
function createArchive(outputExtension, outputPath, cwd) {
if (outputExtension === 'zip') {
createZipArchive(outputPath, cwd);
return;
}
run('tar', ['-czf', outputPath, '-C', cwd, 'qwen-code']);
}
function createZipArchive(outputPath, cwd) {
if (process.platform === 'win32') {
run(
'powershell',
[
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-Command',
'Compress-Archive -LiteralPath $env:QWEN_PACKAGE_ROOT -DestinationPath $env:QWEN_OUTPUT_PATH -Force',
],
{
env: {
...process.env,
QWEN_PACKAGE_ROOT: path.join(cwd, 'qwen-code'),
QWEN_OUTPUT_PATH: outputPath,
},
},
);
return;
}
run('zip', ['-qr', outputPath, 'qwen-code'], { cwd });
}
async function writeSha256Sums(outDir) {
const entries = fs
.readdirSync(outDir)
.filter(
(entry) =>
entry.startsWith('qwen-code-') &&
(entry.endsWith('.tar.gz') || entry.endsWith('.zip')),
)
.sort();
if (entries.length === 0) {
fail(
`No qwen-code archives found in ${outDir}; refusing to write empty SHA256SUMS.`,
);
}
const lines = [];
for (const entry of entries) {
const filePath = path.join(outDir, entry);
const hash = await sha256File(filePath);
lines.push(`${hash} ${entry}`);
}
fs.writeFileSync(path.join(outDir, 'SHA256SUMS'), `${lines.join('\n')}\n`);
}
async function sha256File(filePath) {
const hash = crypto.createHash('sha256');
await pipeline(fs.createReadStream(filePath), hash);
return hash.digest('hex');
}
function run(command, args, options = {}) {
try {
execFileSync(command, args, {
stdio: 'inherit',
...options,
});
} catch (error) {
const detail =
error && typeof error === 'object' && 'message' in error
? `: ${error.message}`
: '';
fail(`Command failed: ${command} ${args.join(' ')}${detail}`);
}
}
function fail(message) {
throw new Error(`Error: ${message}`);
}
export { TARGETS, writeSha256Sums };