返回 CodeWhale
build.mjs
根目录 / crates / tui / extension-host / build.mjs
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
298 lines Plain Text