| 1 | #!/usr/bin/env node |
| 2 | // Release preparation only. Uses an exact local Bun, never installs a runtime. |
| 3 | import { spawnSync } from 'node:child_process' |
| 4 | import { readFileSync, statSync, lstatSync, realpathSync, mkdirSync, copyFileSync, chmodSync, writeFileSync, existsSync } from 'node:fs' |
| 5 | import { dirname, resolve, join, isAbsolute } from 'node:path' |
| 6 | import { fileURLToPath } from 'node:url' |
| 7 | import { createRequire } from 'node:module' |
| 8 | |
| 9 | const require = createRequire(import.meta.url) |
| 10 | const hosts = require('../../npm/codewhale/scripts/compiled-hosts.js') |
| 11 | const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..') |
| 12 | const args = process.argv.slice(2) |
| 13 | const option = (name) => { |
| 14 | const i = args.indexOf(name) |
| 15 | if (i < 0 || !args[i + 1] || args[i + 1].startsWith('--')) throw new Error(`required ${name} VALUE`) |
| 16 | return args[i + 1] |
| 17 | } |
| 18 | const json = (file) => { |
| 19 | if (statSync(file).size > 64 * 1024) throw new Error(`metadata exceeds 64 KiB: ${file}`) |
| 20 | return JSON.parse(readFileSync(file, 'utf8')) |
| 21 | } |
| 22 | const regular = (file) => { |
| 23 | if (lstatSync(file).isSymbolicLink()) throw new Error(`symlink input refused: ${file}`) |
| 24 | const selected = realpathSync(file) |
| 25 | if (!statSync(selected).isFile()) throw new Error(`not a regular local input: ${file}`) |
| 26 | return selected |
| 27 | } |
| 28 | const cleanEnv = { ...process.env, NODE_OPTIONS: '', BUN_OPTIONS: '', BUN_BE_BUN: '0', BUN_JSC_useShadowRealm: '0' } |
| 29 | const run = (binary, argv, extra = {}) => { |
| 30 | const result = spawnSync(binary, argv, { cwd: root, env: cleanEnv, encoding: 'utf8', timeout: 600_000, maxBuffer: 8 * 1024 * 1024, ...extra }) |
| 31 | if (result.error || result.status !== 0) throw result.error ?? new Error(`${binary} failed (${result.status}): ${result.stderr}`) |
| 32 | return result.stdout |
| 33 | } |
| 34 | |
| 35 | if (args.includes('--collect')) { |
| 36 | const input = resolve(option('--collect')) |
| 37 | const output = resolve(option('--output-dir')) |
| 38 | const catalogs = [] |
| 39 | for (const target of Object.keys(hosts.TARGETS)) { |
| 40 | const directory = join(input, `codewhale-compiled-host-${target}`) |
| 41 | const file = join(directory, hosts.HOST_CATALOG) |
| 42 | if (!existsSync(file)) continue |
| 43 | const catalog = hosts.parseCatalog(json(file)) |
| 44 | if (catalog.hosts.length !== 1 || catalog.hosts[0].target !== target) throw new Error(`wrong target receipt at ${file}`) |
| 45 | hosts.verifyDirectory(directory, catalog) |
| 46 | catalogs.push({ directory, catalog }) |
| 47 | } |
| 48 | if (!catalogs.length) throw new Error('compiled-host delivery enabled but no qualified matching-native image exists') |
| 49 | const catalog = { ...catalogs[0].catalog, hosts: catalogs.flatMap(({ catalog }) => catalog.hosts) } |
| 50 | for (const entry of catalogs) for (const key of ['version', 'source_sha', 'bundle_sha256']) { |
| 51 | if (entry.catalog[key] !== catalog[key]) throw new Error(`mixed release ${key}`) |
| 52 | } |
| 53 | hosts.parseCatalog(catalog) |
| 54 | mkdirSync(output, { recursive: true }) |
| 55 | for (const entry of catalogs) for (const file of hosts.assets(entry.catalog).filter((name) => name !== hosts.HOST_CATALOG)) copyFileSync(join(entry.directory, file), join(output, file)) |
| 56 | writeFileSync(join(output, hosts.HOST_CATALOG), JSON.stringify(catalog, null, 2) + '\n') |
| 57 | hosts.verifyDirectory(output, catalog) |
| 58 | console.log(`Collected ${catalog.hosts.length} qualified compiled-host targets`) |
| 59 | } else { |
| 60 | const target = option('--target') |
| 61 | const names = hosts.names(target) |
| 62 | if (hosts.TARGETS[target][0] !== process.platform || hosts.TARGETS[target][1] !== process.arch) throw new Error('compiled host requires a matching native build/qualification runner; no implicit cross-architecture runtime') |
| 63 | const bunArg = option('--bun') |
| 64 | if (!isAbsolute(bunArg)) throw new Error('--bun must be an absolute exact local executable') |
| 65 | const bun = regular(bunArg) |
| 66 | const closureFile = regular(option('--runtime-closure')) |
| 67 | const closure = json(closureFile) |
| 68 | const version = option('--version') |
| 69 | const sourceSha = option('--source-sha') |
| 70 | const bundle = join(root, 'crates/tui/extension-host/dist/codewhale-extension-host.mjs') |
| 71 | const digest = hosts.sha256(readFileSync(bundle)) |
| 72 | const runtimeHash = hosts.sha256(readFileSync(bun)) |
| 73 | const revision = run(bun, ['--revision']).trim() |
| 74 | if (closure.schema !== 1 || typeof closure.runtime_revision !== 'string' || !/^[a-f0-9]{40}$/.test(closure.runtime_revision) || closure.bundle_sha256 !== digest || closure.source_commit !== sourceSha || closure.runtime_sha256 !== runtimeHash || revision !== `${closure.runtime_version}+${closure.runtime_revision.slice(0, 9)}`) throw new Error('exact local Bun revision/digest differs from reviewed runtime closure') |
| 75 | // Executed runtime facts distinguish native ARM from an x64 Bun under |
| 76 | // emulation. The runner label and a hash alone cannot establish image arch. |
| 77 | const runtimeInfo = JSON.parse(run(bun, ['--no-install', '--no-env-file', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '--no-addons', '-p', 'JSON.stringify({platform:process.platform,arch:process.arch})'])) |
| 78 | if (runtimeInfo.platform !== process.platform || runtimeInfo.arch !== process.arch) throw new Error('selected Bun process identity differs from native qualification target; emulation proof does not transfer') |
| 79 | const input = (field) => regular(resolve(dirname(closureFile), closure[field])) |
| 80 | const notices = input('notices') |
| 81 | const source = input('relink_source') |
| 82 | hosts.verifyBytes(readFileSync(notices), closure.notices_sha256, 'runtime notices') |
| 83 | hosts.verifyBytes(readFileSync(source), closure.source_sha256, 'corresponding relink source') |
| 84 | if (closure.license_closure !== 'complete' || closure.corresponding_source !== 'complete') throw new Error('runtime copyright/license texts and LGPL corresponding source/relink inputs remain incomplete; delivery refused') |
| 85 | // This reviewed input must contain actual matching texts, not component |
| 86 | // names or links alone. The exact-build source bundle is a separate input. |
| 87 | const requiredComponents = ['bun', 'JavaScriptCore', 'WebCore', 'tinycc', 'boringssl', 'brotli', 'libarchive', 'lolhtml', 'lshpack', 'lsqpack', 'lsquic', 'mimalloc', 'picohttpparser', 'zstd', 'simdutf', 'usockets', 'uwebsockets', 'zlib', 'cares', 'icu', 'libbase64', 'libuv', 'libdeflate', 'libjpeg-turbo', 'libspng', 'libwebp', 'highway', 'hdrhistogram', 'uucode', 'tigerbeetle-io', 'llvm-libcxxabi', 'embedded-polyfills', 'rust-dependencies'] |
| 88 | if (!Array.isArray(closure.components) || !requiredComponents.every((name) => closure.components.includes(name)) || !closure.notice_components || typeof closure.notice_components !== 'object') throw new Error('incomplete runtime component closure') |
| 89 | const allNotices = readFileSync(notices) |
| 90 | for (const component of requiredComponents) { |
| 91 | const part = closure.notice_components[component] |
| 92 | if (!part || typeof part.file !== 'string' || typeof part.sha256 !== 'string') throw new Error(`missing matching notice text for ${component}`) |
| 93 | const file = regular(resolve(dirname(closureFile), part.file)) |
| 94 | if (statSync(file).size > 1024 * 1024) throw new Error(`oversized component notice ${component}`) |
| 95 | const text = readFileSync(file) |
| 96 | hosts.verifyBytes(text, part.sha256, component) |
| 97 | if (!text.length || !allNotices.includes(text)) throw new Error(`combined notices omit actual ${component} text`) |
| 98 | } |
| 99 | if (!Array.isArray(closure.source_inputs) || !['bun', 'webkit', 'tinycc', 'relink-recipe'].every((name) => closure.source_inputs.some((entry) => entry.name === name && typeof entry.root === 'string'))) throw new Error('missing LGPL corresponding source and relink recipe inventory') |
| 100 | const members = run('tar', ['-tzf', source]).trim().split('\n') |
| 101 | if (members.some((name) => name.startsWith('/') || name.split('/').includes('..') || name.includes('\\'))) throw new Error('unsafe corresponding-source archive paths') |
| 102 | for (const entry of closure.source_inputs) { |
| 103 | if (!/^[A-Za-z0-9_.\/-]+$/.test(entry.root) || entry.root.startsWith('/') || entry.root.split('/').includes('..') || !members.some((member) => member === entry.root || member.startsWith(entry.root.replace(/\/$/, '') + '/'))) throw new Error(`corresponding-source archive omits ${entry.name}`) |
| 104 | } |
| 105 | if (closure.source_inputs.find((entry) => entry.name === 'bun').revision !== closure.runtime_revision || closure.source_inputs.find((entry) => entry.name === 'webkit').revision !== closure.webkit_revision) throw new Error('corresponding source revisions differ from the actual runtime') |
| 106 | const selectedImage = regular(option('--compiled-image')) |
| 107 | const imageHash = hosts.sha256(readFileSync(selectedImage)) |
| 108 | const selectedInfo = JSON.parse(run(selectedImage, ['--codewhale-host-info'])) |
| 109 | if (selectedInfo.kind !== 'codewhale-extension-host' || selectedInfo.runtime !== 'bun' || selectedInfo.bundle_sha256 !== digest || selectedInfo.version !== closure.runtime_version || selectedInfo.platform !== runtimeInfo.platform || selectedInfo.arch !== runtimeInfo.arch) throw new Error('executed compiled image identity differs from selected Bun/native qualification target') |
| 110 | const native = json(regular(option('--native-receipt'))) |
| 111 | if (typeof native.log !== 'string' || !/^[A-Za-z0-9_.-]+$/.test(native.log) || native.log === '.' || native.log === '..') throw new Error('Native qualification log must be a contained basename') |
| 112 | const nativeLog = regular(resolve(dirname(option('--native-receipt')), native.log)) |
| 113 | hosts.verifyBytes(readFileSync(nativeLog), native.log_sha256, 'Native compiled-host qualification log') |
| 114 | if (native.scope !== 'native-compiled-host' || native.source_sha !== sourceSha || native.bundle_sha256 !== digest || native.runtime_sha256 !== runtimeHash || native.host_sha256 !== imageHash || native.platform !== process.platform || native.arch !== process.arch || (!Number.isSafeInteger(native.passed) || native.passed < hosts.nativeMinimum(target)) || native.failed !== 0 || native.skipped !== 0) throw new Error('missing exact-source matching Native compiled-image containment and memory qualification; fake Core alone is insufficient') |
| 115 | if (process.platform === 'linux' && (native.libc?.scope !== 'native-runner-observed' || native.libc?.family !== closure.libc)) throw new Error('Linux runtime libc claim is not qualified by this actual Native runner; glibc proof does not qualify musl') |
| 116 | const output = resolve(option('--output-dir')) |
| 117 | mkdirSync(output, { recursive: true }) |
| 118 | const binary = join(output, hosts.HOST_NAME + (process.platform === 'win32' ? '.exe' : '')) |
| 119 | if (selectedImage !== binary) copyFileSync(selectedImage, binary) |
| 120 | if (hosts.sha256(readFileSync(binary)) !== imageHash) throw new Error('compiled image changed during staging') |
| 121 | if (process.platform !== 'win32') chmodSync(binary, 0o755) |
| 122 | const testLog = run(process.execPath, ['--test', '--test-reporter=tap', 'test/compiled-host.test.mjs'], { cwd: join(root, 'crates/tui/extension-host'), env: { ...cleanEnv, CODEWHALE_COMPILED_HOST_TEST_BINARY: binary, CODEWHALE_BUN_TEST_BINARY: bun } }) |
| 123 | writeFileSync(join(output, 'compiled-host-qualification.log'), testLog) |
| 124 | const count = (name) => Number(testLog.match(new RegExp(`^# ${name} (\\d+)$`, 'm'))?.[1] ?? -1) |
| 125 | if (count('pass') !== hosts.compiledMinimum(target) || count('fail') !== 0 || count('skipped') !== 0) throw new Error('compiled-image suite did not run every required case successfully') |
| 126 | const info = JSON.parse(run(binary, ['--codewhale-host-info'])) |
| 127 | if (info.kind !== 'codewhale-extension-host' || info.bundle_sha256 !== digest || info.version !== closure.runtime_version || info.runtime !== 'bun' || info.platform !== runtimeInfo.platform || info.arch !== runtimeInfo.arch) throw new Error('compiled identity differs after actual image qualification') |
| 128 | if (hosts.sha256(readFileSync(binary)) !== imageHash) throw new Error('qualified image changed during the acceptance probes') |
| 129 | copyFileSync(binary, join(output, names.binary)) |
| 130 | // These are complete runtime + canonical JS dependency notices and complete |
| 131 | // corresponding source, not a URL-only promise or a fabricated MIT label. |
| 132 | writeFileSync(join(output, names.notices), Buffer.concat([readFileSync(notices), Buffer.from('\n\nCodewhale extension host dependencies:\n'), readFileSync(join(root, 'crates/tui/extension-host/dist/LICENSES.txt'))])) |
| 133 | copyFileSync(source, join(output, names.source)) |
| 134 | const catalog = { schema: 1, version, source_sha: sourceSha, bundle_sha256: digest, hosts: [{ |
| 135 | target, asset: names.binary, sha256: hosts.sha256(readFileSync(binary)), |
| 136 | notices_asset: names.notices, notices_sha256: hosts.sha256(readFileSync(join(output, names.notices))), |
| 137 | source_asset: names.source, source_sha256: hosts.sha256(readFileSync(source)), |
| 138 | runtime_version: closure.runtime_version, runtime_revision: closure.runtime_revision, runtime_sha256: runtimeHash, |
| 139 | webkit_revision: closure.webkit_revision, bundle_sha256: digest, source_commit: sourceSha, |
| 140 | native_platform: info.platform, native_arch: info.arch, libc: closure.libc, |
| 141 | passed: count('pass'), failed: 0, skipped: 0, test_log_sha256: hosts.sha256(Buffer.from(testLog)), |
| 142 | native_passed: native.passed, native_failed: 0, native_skipped: 0, native_log_sha256: native.log_sha256, |
| 143 | native_runner_libc: native.libc ?? null, |
| 144 | license_closure: 'complete', relink_source: 'complete', |
| 145 | }] } |
| 146 | hosts.parseCatalog(catalog, version) |
| 147 | writeFileSync(join(output, hosts.HOST_CATALOG), JSON.stringify(catalog, null, 2) + '\n') |
| 148 | hosts.verifyDirectory(output, catalog) |
| 149 | console.log(`Staged opt-in ${target}; Node remains default`) |
| 150 | } |
| 151 |