morphit/apps/ops-cli/scripts/build.mjs

134 lines
6.4 KiB
JavaScript

/**
* ops-cli production build (cp162).
*
* Bundles the operator CLI into a single self-contained
* `dist/main.js` with a Node shebang, so the published `bin`
* points at runnable JavaScript and the runtime no longer
* depends on `tsx`. This closes the cp161 root cause for good:
* the operator's `morphit-ops init` runs under plain `node`,
* with no source-transpilation step at invocation time.
*
* Why bundle (esbuild) rather than plain `tsc` like mcp-server:
* ops-cli has two structural traits that make a clean `tsc`
* emit impractical —
* 1. ~92 `.ts`-extension import specifiers across 24 files
* (the tsx-source-run model; `allowImportingTsExtensions`
* requires `noEmit`, so tsc can't emit them as-is).
* 2. Two cross-workspace reaches that escape ops-cli's
* `rootDir: src` —
* - apps/relay/src/crypto/keyEnvelope.ts (static + dynamic)
* - apps/indexer/src/lib/feeAmountCalc.ts (static)
* plus the `@morphit/operator-config` workspace package,
* which is itself source-only (main: src/index.ts).
*
* A bundler inlines all of that into one file, resolving the
* `.ts` extensions and the cross-workspace source at build time
* — touching only this workspace. Single-file bundling is the
* standard ship strategy for a Node CLI anyway.
*
* Externals (NOT bundled — resolved from node_modules at runtime):
* - `pg` — the Postgres driver; a real npm dependency with its
* own internal requires + optional native bits. Bundling it
* is fragile; it stays a production dependency and is required
* normally at runtime.
* - `node:*` builtins — never bundled.
*
* Everything else (ops-cli source + keyEnvelope + feeAmountCalc
* + operator-config) is inlined.
*
* The output keeps a `#!/usr/bin/env node` shebang via the
* banner, matching apps/mcp-server/dist/main.js.
*/
import { build } from 'esbuild';
import { chmodSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const here = dirname(fileURLToPath(import.meta.url));
const root = resolve(here, '..');
const outfile = resolve(root, 'dist/main.js');
await build({
entryPoints: [resolve(root, 'src/main.ts')],
outfile,
bundle: true,
platform: 'node',
// Node 22 is the floor (engines.node >=22.0.0); target its V8.
target: 'node22',
format: 'esm',
// Keep these resolved from node_modules at runtime, not inlined.
external: ['pg'],
// cp178 — CRITICAL ESM/CJS interop fix. We bundle to `format:
// 'esm'`, but several inlined deps are CommonJS and call
// `require(...)` at module-eval time — notably the broadcast
// path's @beblurt/dblurt → cross-fetch → node-fetch, which does
// `require('stream')`. esbuild replaces `require` in an ESM
// bundle with a `__require` shim that THROWS ("Dynamic require of
// 'stream' is not supported") for Node builtins unless a real
// `require` exists in scope. Without this banner the operator's
// `register` step dies at `await import('@beblurt/dblurt')` with a
// require error that the catch-block then mis-reports as "dblurt
// is not installed" — un-fixable by reinstalling, since the
// package IS installed; the bundle just can't evaluate it. The
// `createRequire(import.meta.url)` banner gives the bundle a real
// CJS `require`, so `__require` falls through to it for builtins.
// This is esbuild's documented ESM-output interop pattern.
// NOTE: the banner is plain JS (no leading `#!`), so it does NOT
// interfere with the shebang handling below — esbuild emits the
// entry's own shebang FIRST, then this banner; the post-process
// strips that first shebang line and prepends the Node one,
// leaving the banner intact.
banner: {
js: "import { createRequire as __ops_createRequire } from 'node:module';\nimport { fileURLToPath as __ops_fileURLToPath } from 'node:url';\nimport { dirname as __ops_dirname } from 'node:path';\nconst require = __ops_createRequire(import.meta.url);\nconst __filename = __ops_fileURLToPath(import.meta.url);\nconst __dirname = __ops_dirname(__filename);"
},
// NOTE: we deliberately do NOT use esbuild's `banner` for the
// shebang. esbuild preserves the entry file's own leading
// shebang (`#!/usr/bin/env -S npx tsx` in src/main.ts — kept
// for the dev/tsx-source run path), so a banner shebang would
// produce TWO shebang lines and an "Invalid or unexpected
// token" at runtime. Instead we strip any leading shebang
// from the bundled output and prepend exactly one Node shebang
// in the post-process step below.
sourcemap: true,
// Minification off: operator-facing tool, readable stack traces
// matter more than a few KB.
minify: false,
// esbuild reads each bundled file's nearest tsconfig.json and
// emits an advisory "Unrecognized target environment ES2023"
// for TypeScript's capitalized `target` value (esbuild expects
// the lowercase form, e.g. `es2023`). It is purely advisory:
// esbuild IGNORES the tsconfig `target` because we set our own
// (`target: 'node22'` above), so the emitted bundle is byte-for-
// byte unaffected. tsc — run authoritatively via
// workspace-typecheck-smoke — is what actually validates these
// tsconfigs, which makes esbuild's tsconfig advisories redundant
// here. Silence just that message class so the operator's first
// `npm run build` is clean instead of showing 5 scary ▲ WARNINGs
// about a non-issue (cp181 handoff-hygiene polish). `logOverride`
// is scoped to the `tsconfig.json` message id only; real build
// errors (syntax, unresolved imports, the cp178 dynamic-require
// class) are unaffected and still surface.
logOverride: { 'tsconfig.json': 'silent' },
logLevel: 'info'
});
// Post-process: guarantee exactly one shebang, and make it the
// Node one. The bundled output may begin with the source file's
// `#!/usr/bin/env -S npx tsx` shebang (esbuild preserves it); the
// runtime artifact must instead run under plain `node`.
const NODE_SHEBANG = '#!/usr/bin/env node';
let code = readFileSync(outfile, 'utf8');
if (code.startsWith('#!')) {
// Drop the existing first-line shebang (whatever it is).
const nl = code.indexOf('\n');
code = nl === -1 ? '' : code.slice(nl + 1);
}
code = NODE_SHEBANG + '\n' + code;
writeFileSync(outfile, code);
// Ensure the output is executable (the bin symlink invokes it).
chmodSync(outfile, 0o755);
console.log(`✓ ops-cli bundled → ${outfile}`);