| 1 | // Build the single-file host bundle that the Rust binary embeds. |
| 2 | // |
| 3 | // `dist/` is committed so `cargo build` never needs Node or npm; CI rebuilds |
| 4 | // and fails on drift (`git diff --exit-code dist`). |
| 5 | import { build } from 'esbuild' |
| 6 | import { createHash } from 'node:crypto' |
| 7 | import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' |
| 8 | import { dirname, join } from 'node:path' |
| 9 | import { fileURLToPath } from 'node:url' |
| 10 | |
| 11 | const here = dirname(fileURLToPath(import.meta.url)) |
| 12 | const outdir = join(here, 'dist') |
| 13 | mkdirSync(outdir, { recursive: true }) |
| 14 | |
| 15 | // Built-in host modules (tier 0): each `src/builtin/<id>.ts` is built to |
| 16 | // `dist/builtin/<id>.mjs`, the file the core activates under the owner id |
| 17 | // `host:<id>`, and `dist/builtin-modules.json` records the SHA-256 of every |
| 18 | // one. The Rust table `BUILTIN_MODULES` (src/extension_host/tier.rs) pins the |
| 19 | // same digests, and a Rust test fails when the two disagree. The committed |
| 20 | // host:mcp module is an explicitly selected SDK adapter over Rust broker sessions. |
| 21 | // They are built before the host bundle, which embeds the digests. |
| 22 | const builtinSource = join(here, 'src/builtin') |
| 23 | const builtinOut = join(outdir, 'builtin') |
| 24 | rmSync(builtinOut, { recursive: true, force: true }) // a removed module leaves nothing behind |
| 25 | const builtinIds = existsSync(builtinSource) |
| 26 | ? readdirSync(builtinSource) |
| 27 | .filter((file) => file.endsWith('.ts')) |
| 28 | .map((file) => file.slice(0, -'.ts'.length)) |
| 29 | .sort() |
| 30 | : [] |
| 31 | const builtinDigests = {} |
| 32 | const builtinMetafiles = [] |
| 33 | for (const id of builtinIds) { |
| 34 | // Mirrors `valid_module_id` in tier.rs: the id is also a file name. |
| 35 | if (!/^[a-z][a-z0-9-]{0,63}$/.test(id)) throw new Error(`src/builtin/${id}.ts is not a valid built-in module name`) |
| 36 | const outfile = join(builtinOut, `${id}.mjs`) |
| 37 | const built = await build({ |
| 38 | entryPoints: [join(builtinSource, `${id}.ts`)], |
| 39 | outfile, |
| 40 | absWorkingDir: here, |
| 41 | metafile: true, |
| 42 | bundle: true, |
| 43 | platform: 'node', |
| 44 | format: 'esm', |
| 45 | target: 'node22.19', |
| 46 | sourcemap: false, |
| 47 | minify: false, |
| 48 | legalComments: 'none', |
| 49 | charset: 'utf8', |
| 50 | logLevel: 'warning', |
| 51 | banner: { js: `// codewhale built-in host module ${id} — generated by crates/tui/extension-host/build.mjs; do not edit.` }, |
| 52 | }) |
| 53 | builtinMetafiles.push(built.metafile) |
| 54 | builtinDigests[id] = createHash('sha256').update(readFileSync(outfile)).digest('hex') |
| 55 | } |
| 56 | writeFileSync(join(outdir, 'builtin-modules.json'), JSON.stringify({ modules: builtinDigests }, null, 2) + '\n') |
| 57 | |
| 58 | const compositionAlias = { |
| 59 | '@deepseek-ai/cordis-plugin-loader': join(here, 'src/dsh/upstream/loader/src/index.ts'), |
| 60 | } |
| 61 | |
| 62 | const hostBuild = await build({ |
| 63 | alias: compositionAlias, |
| 64 | entryPoints: [join(here, 'src/main.ts')], |
| 65 | outfile: join(outdir, 'codewhale-extension-host.mjs'), |
| 66 | absWorkingDir: here, |
| 67 | metafile: true, |
| 68 | bundle: true, |
| 69 | platform: 'node', |
| 70 | format: 'esm', |
| 71 | target: 'node22.19', |
| 72 | // Deterministic output: no sourcemap paths, no timestamps, no minification |
| 73 | // (a readable bundle keeps the reviewed-bytes story honest). |
| 74 | sourcemap: false, |
| 75 | minify: false, |
| 76 | legalComments: 'none', |
| 77 | charset: 'utf8', |
| 78 | logLevel: 'warning', |
| 79 | // `host/hello` reports these, so the core can check the bundle and its own |
| 80 | // pinned table agree (`tier.ts`, `builtinModuleDigests`). |
| 81 | define: { __BUILTIN_MODULE_DIGESTS__: JSON.stringify(builtinDigests) }, |
| 82 | banner: { |
| 83 | js: '// codewhale-extension-host — generated by crates/tui/extension-host/build.mjs; do not edit.\n// Third-party notices: LICENSES.txt next to this file.', |
| 84 | }, |
| 85 | }) |
| 86 | |
| 87 | // Test-only reviewed hook bridge: Node tests exercise the exact pinned parser. |
| 88 | await build({ |
| 89 | entryPoints: [join(here, 'src/dsh/shell-hooks.ts')], |
| 90 | outfile: join(outdir, 'shell-hooks.mjs'), |
| 91 | absWorkingDir: here, |
| 92 | bundle: true, |
| 93 | platform: 'node', |
| 94 | format: 'esm', |
| 95 | target: 'node22.19', |
| 96 | sourcemap: false, |
| 97 | minify: false, |
| 98 | legalComments: 'none', |
| 99 | charset: 'utf8', |
| 100 | logLevel: 'warning', |
| 101 | }) |
| 102 | |
| 103 | // Trusted offline composition reviewer. Embedded beside the host, but has |
| 104 | // no HostRoot, RPC handler, plugin import or expression evaluation entrypoint. |
| 105 | const reviewBuild = await build({ |
| 106 | alias: compositionAlias, |
| 107 | entryPoints: [join(here, 'src/dsh/review-main.ts')], |
| 108 | outfile: join(outdir, 'dsh-composition-review.mjs'), |
| 109 | absWorkingDir: here, |
| 110 | metafile: true, |
| 111 | bundle: true, |
| 112 | platform: 'node', |
| 113 | format: 'esm', |
| 114 | target: 'node22.19', |
| 115 | sourcemap: false, |
| 116 | minify: false, |
| 117 | legalComments: 'none', |
| 118 | charset: 'utf8', |
| 119 | logLevel: 'warning', |
| 120 | banner: { js: '// trusted nonexecuting DSH composition reviewer; third-party notices: LICENSES.txt' }, |
| 121 | }) |
| 122 | |
| 123 | // Test-only pure adapters: the production harness imports exactly this source. |
| 124 | await build({entryPoints:[join(here,'src/builtin/shared/stock-adapters.ts')],outfile:join(outdir,'stock-adapters.mjs'),bundle:true,platform:'node',format:'esm',target:'node22.19',sourcemap:false,minify:false,legalComments:'none',charset:'utf8',logLevel:'warning'}) |
| 125 | |
| 126 | // Test-only protocol module (codec + validators) so `node --test` can check |
| 127 | // the corpus against the exact code the host runs, without booting a host. |
| 128 | // Not embedded in the Rust binary. |
| 129 | await build({ |
| 130 | entryPoints: [join(here, 'src/protocol.ts')], |
| 131 | outfile: join(outdir, 'protocol.mjs'), |
| 132 | bundle: true, |
| 133 | platform: 'node', |
| 134 | format: 'esm', |
| 135 | target: 'node22.19', |
| 136 | sourcemap: false, |
| 137 | minify: false, |
| 138 | legalComments: 'none', |
| 139 | charset: 'utf8', |
| 140 | logLevel: 'warning', |
| 141 | banner: { js: '// generated by crates/tui/extension-host/build.mjs from src/protocol.ts; do not edit.' }, |
| 142 | }) |
| 143 | |
| 144 | // Test-only RPC peer, for the unit tests of its cancellation. Not embedded. |
| 145 | await build({ |
| 146 | entryPoints: [join(here, 'src/rpc.ts')], |
| 147 | outfile: join(outdir, 'rpc.mjs'), |
| 148 | bundle: true, |
| 149 | platform: 'node', |
| 150 | format: 'esm', |
| 151 | target: 'node22.19', |
| 152 | sourcemap: false, |
| 153 | minify: false, |
| 154 | legalComments: 'none', |
| 155 | charset: 'utf8', |
| 156 | logLevel: 'warning', |
| 157 | banner: { js: '// generated by crates/tui/extension-host/build.mjs from src/rpc.ts; do not edit.' }, |
| 158 | }) |
| 159 | |
| 160 | // Test-only skill-root shim, bundled so tests remain dependency-free before npm ci. |
| 161 | await build({ |
| 162 | entryPoints: [join(here, 'src/shims/skills.ts')], |
| 163 | outfile: join(outdir, 'skills.mjs'), |
| 164 | absWorkingDir: here, |
| 165 | bundle: true, |
| 166 | platform: 'node', |
| 167 | format: 'esm', |
| 168 | target: 'node22.19', |
| 169 | sourcemap: false, |
| 170 | minify: false, |
| 171 | legalComments: 'none', |
| 172 | charset: 'utf8', |
| 173 | logLevel: 'warning', |
| 174 | banner: { js: '// generated by crates/tui/extension-host/build.mjs from src/shims/skills.ts; do not edit.' }, |
| 175 | }) |
| 176 | |
| 177 | // Source-focused raw roster tests use the same discovery/receipt adapter. |
| 178 | await build({entryPoints:[join(here,'src/dsh/agent-presets.ts')],outfile:join(outdir,'agent-presets.mjs'),alias:compositionAlias,bundle:true,platform:'node',format:'esm',target:'node22.19',sourcemap:false,minify:false,legalComments:'none',charset:'utf8',logLevel:'warning'}) |
| 179 | |
| 180 | // Third-party notices (`dist/LICENSES.txt`), generated from what the bundler |
| 181 | // actually put in the host bundle: every package that contributed an input |
| 182 | // file, with its own LICENSE file. A package added to or dropped from the |
| 183 | // bundle changes this file, and CI's `git diff --exit-code -- dist` then |
| 184 | // catches a stale one. The Rust core embeds this file and writes it beside the |
| 185 | // materialised bundle, so the banner's promise ("LICENSES.txt next to this |
| 186 | // file") holds for every installed copy. |
| 187 | const LICENSE_FILES = ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'LICENCE', 'LICENCE.md', 'license', 'license.md', 'COPYING'] |
| 188 | |
| 189 | function bundledPackages(metafile) { |
| 190 | const found = new Map() |
| 191 | for (const input of Object.keys(metafile.inputs)) { |
| 192 | const marker = 'node_modules/' |
| 193 | const at = input.lastIndexOf(marker) |
| 194 | if (at < 0) continue |
| 195 | const [first, second] = input.slice(at + marker.length).split('/') |
| 196 | const name = first.startsWith('@') ? `${first}/${second}` : first |
| 197 | found.set(name, join(here, input.slice(0, at + marker.length), name)) |
| 198 | } |
| 199 | return [...found.entries()].sort(([a], [b]) => (a < b ? -1 : 1)).map(([name, root]) => ({ name, root })) |
| 200 | } |
| 201 | |
| 202 | function readLicense(root, label) { |
| 203 | for (const file of LICENSE_FILES) { |
| 204 | try { |
| 205 | return readFileSync(join(root, file), 'utf8').trim() |
| 206 | } catch {} |
| 207 | } |
| 208 | throw new Error(`${label} is bundled but ships no LICENSE file in ${root}; add its notice by hand before releasing`) |
| 209 | } |
| 210 | |
| 211 | // Source vendored into `src/` verbatim rather than imported from node_modules. |
| 212 | // The metafile cannot see these, so each names the file that carries the |
| 213 | // excerpt and the bundled package whose licence text it shares (checked below). |
| 214 | const EXCERPTED = [ |
| 215 | { |
| 216 | name: '@deepseek-ai/dsh-tools', |
| 217 | version: '0.1.7-alpha.2', |
| 218 | license: 'MIT', |
| 219 | file: 'src/dsh/dsh-tools-compat.js', |
| 220 | licenseOf: '@deepseek-ai/dsh-util-values', |
| 221 | }, |
| 222 | ] |
| 223 | |
| 224 | const packageInputs = [hostBuild.metafile, reviewBuild.metafile, ...builtinMetafiles].flatMap(bundledPackages) |
| 225 | const packagesByIdentity = new Map() |
| 226 | for (const { name, root } of packageInputs) { |
| 227 | const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) |
| 228 | if (typeof pkg.license !== 'string' || pkg.license.length === 0) throw new Error(`${name} declares no license in package.json`) |
| 229 | const entry = { name: pkg.name, version: pkg.version, license: pkg.license, text: readLicense(root, name) } |
| 230 | const identity = `${entry.name}@${entry.version}` |
| 231 | const prior = packagesByIdentity.get(identity) |
| 232 | if (prior && (prior.license !== entry.license || prior.text !== entry.text)) throw new Error(`${identity} contributes conflicting licence texts`) |
| 233 | packagesByIdentity.set(identity, entry) |
| 234 | } |
| 235 | const packages = [...packagesByIdentity.values()] |
| 236 | if (packages.length === 0) throw new Error('the host bundle contains no node_modules input: the notice generator is reading the wrong metafile') |
| 237 | |
| 238 | const entries = packages.map((pkg) => ({ ...pkg, note: '' })) |
| 239 | for (const excerpt of EXCERPTED) { |
| 240 | const source = readFileSync(join(here, excerpt.file), 'utf8') |
| 241 | if (!source.includes(`${excerpt.name}@${excerpt.version}`)) { |
| 242 | throw new Error(`${excerpt.file} no longer names ${excerpt.name}@${excerpt.version}; update EXCERPTED in build.mjs`) |
| 243 | } |
| 244 | const shared = packages.find((pkg) => pkg.name === excerpt.licenseOf) |
| 245 | if (!shared) throw new Error(`${excerpt.licenseOf} (licence text of ${excerpt.name}) is not bundled`) |
| 246 | entries.push({ |
| 247 | name: excerpt.name, |
| 248 | version: excerpt.version, |
| 249 | license: excerpt.license, |
| 250 | text: shared.text, |
| 251 | note: `Verbatim excerpts of this package are in ${excerpt.file}, under the same licence text as ${excerpt.licenseOf} above.`, |
| 252 | }) |
| 253 | } |
| 254 | for (const [folder, version] of [['loader', '1.0.3'], ['include', '1.0.7'], ['group', '1.0.2']]) { |
| 255 | entries.push({ |
| 256 | name: `@deepseek-ai/cordis-plugin-${folder}`, |
| 257 | version, |
| 258 | license: 'MIT', |
| 259 | text: readLicense(join(here, 'src/dsh/upstream', folder), `vendored ${folder}`), |
| 260 | note: 'Pinned deepseek-harness 0d1f50007f9bca3f52b06e1c3074fa14d5fb0720. Loader native-internals probe disabled; other runtime semantics reused.', |
| 261 | }) |
| 262 | } |
| 263 | entries.push({name:'@deepseek-ai/dsh-agent-presets',version:'0.1.6-alpha.1',license:'MIT',text:readLicense(join(here,'src/dsh/upstream/agent-presets'),'vendored agent-presets'),note:'Pinned deepseek-harness 0d1f50007f9bca3f52b06e1c3074fa14d5fb0720. Discovery/metadata/specifier/inventory algorithms use mandatory receipt-backed IO; Core owns selection, sessions and writable roots.'}) |
| 264 | for (const name of ['dsh-hook-protocol', 'dsh-hooks-claude-code', 'dsh-hooks-codex']) { |
| 265 | entries.push({ |
| 266 | name: `@deepseek-ai/${name}`, |
| 267 | version: '0.1.6-alpha.1', |
| 268 | license: 'MIT', |
| 269 | text: readLicense(join(here, 'src/dsh/upstream/hooks'), `vendored ${name}`), |
| 270 | note: 'Pinned deepseek-harness 0d1f50007f9bca3f52b06e1c3074fa14d5fb0720. Pure matcher/codec/merge and configuration parsers reused; local imports and session-writer type declarations adapted. Core retains scheduling and steering.', |
| 271 | }) |
| 272 | } |
| 273 | for (const name of ['dsh-persona', 'dsh-system-prompt']) { |
| 274 | entries.push({ |
| 275 | name: `@deepseek-ai/${name}`, |
| 276 | version: '0.1.6-alpha.1', |
| 277 | license: 'MIT', |
| 278 | text: readLicense(join(here, 'src/dsh/upstream/hooks'), `adapted ${name}`), |
| 279 | note: 'Pinned deepseek-harness 0d1f50007f9bca3f52b06e1c3074fa14d5fb0720. Additive persona configuration and strict simple template mapping adapted in src/dsh/persona.ts and the Rust prompt renderer; source and license receipts: src/dsh/persona.UPSTREAM.json. Core retains prompt assembly and accepted model/workspace values.', |
| 280 | }) |
| 281 | } |
| 282 | entries.sort((a, b) => (a.name < b.name ? -1 : 1)) |
| 283 | |
| 284 | const sections = [ |
| 285 | 'Third-party software bundled into codewhale-extension-host.mjs, dsh-composition-review.mjs and builtin/*.mjs.', |
| 286 | 'Generated by crates/tui/extension-host/build.mjs from the bundler metafile; do not edit.', |
| 287 | '', |
| 288 | 'Packages:', |
| 289 | ...entries.map((entry) => ` ${entry.name}@${entry.version} (${entry.license})`), |
| 290 | ] |
| 291 | for (const entry of entries) { |
| 292 | sections.push('', '='.repeat(72), `${entry.name}@${entry.version} (${entry.license})`, '='.repeat(72)) |
| 293 | if (entry.note) sections.push('', entry.note) |
| 294 | sections.push('', entry.text) |
| 295 | } |
| 296 | // Preserve every notice word while keeping generated text free of trailing whitespace. |
| 297 | writeFileSync(join(outdir, 'LICENSES.txt'), sections.join('\n').replace(/[ \t]+$/gm, '') + '\n') |
| 298 |