// Baut das Windows-Paket, das Abdu bekommt. // // node scripts/build-package.ts [--version 0.5.0] [--out dist] // // Ergebnis in dist/: // abdulive--win-x64.zip das Paket // abdulive.json Manifest fuer den Updater // // Laeuft auf jedem Rechner, auch unter Linux in der CI - es wird nichts // ausgefuehrt, nur heruntergeladen, entpackt und wieder eingepackt. // Siehe ADR-0007. import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { readZip, writeZip } from '#core/zip.ts'; const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); // ---------------------------------------------------------------- Bausteine /** * Node liefert versionierte, dauerhafte Adressen. ffmpeg und whisper.cpp * nicht: BtbN raeumt alte Autobuilds nach einigen Wochen ab. Deshalb wird * die jeweils aktuelle Ausgabe ueber die GitHub-API bestimmt und ihr genauer * Name samt Pruefsumme ins Paket geschrieben - reproduzierbar ist damit das * Paket, nicht die Adresse. */ const NODE_VERSION = 'v24.9.0'; type Component = { name: string; url: () => Promise; /** Welche Dateien aus dem Archiv, und wohin im Paket. */ pick: (entryName: string) => string | null; }; const COMPONENTS: Component[] = [ { name: 'node', url: async () => `https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-win-x64.zip`, pick: (n) => (n.endsWith('/node.exe') ? 'node.exe' : null), }, { name: 'ffmpeg', url: () => githubAsset('BtbN/FFmpeg-Builds', /^ffmpeg-n\d[\w.-]*win64-gpl-shared-[\d.]+\.zip$/), // Aus dem shared-Build nur bin/: die beiden Programme und ihre DLLs. // ffplay wird nicht gebraucht. pick: (n) => { const m = /\/bin\/(.+)$/.exec(n); if (!m?.[1] || m[1].startsWith('ffplay')) return null; return /\.(exe|dll)$/i.test(m[1]) ? `bin/${m[1]}` : null; }, }, { name: 'whisper', url: () => githubAsset('ggml-org/whisper.cpp', /^whisper-bin-x64\.zip$/), // whisper-cli plus die Bibliotheken. Die ggml-cpu-*.dll sind Varianten // fuer verschiedene Befehlssaetze; ausgewaehlt wird zur Laufzeit, es // muessen also alle mit. pick: (n) => { const m = /^Release\/(.+)$/.exec(n); const f = m?.[1]; if (!f) return null; if (f === 'whisper-cli.exe') return `bin/${f}`; if (/^(whisper|ggml)[\w.-]*\.dll$/.test(f)) return `bin/${f}`; return null; }, }, ]; async function githubAsset(repoName: string, pattern: RegExp): Promise { const res = await fetch(`https://api.github.com/repos/${repoName}/releases/latest`, { headers: { 'user-agent': 'abdulive-build' }, }); if (!res.ok) throw new Error(`GitHub-API ${res.status} fuer ${repoName}`); const rel = await res.json() as { assets: Array<{ name: string; browser_download_url: string }> }; const asset = rel.assets.find((a) => pattern.test(a.name)); if (!asset) throw new Error(`Kein passendes Archiv in ${repoName} (${pattern})`); return asset.browser_download_url; } // ---------------------------------------------------------------- Werkzeug const sha256 = (b: Buffer) => crypto.createHash('sha256').update(b).digest('hex'); async function download(url: string): Promise { const cacheDir = path.join(repo, '.cache'); await fs.mkdir(cacheDir, { recursive: true }); const file = path.join(cacheDir, path.basename(new URL(url).pathname)); const cached = await fs.readFile(file).catch(() => null); if (cached) { console.log(` aus dem Zwischenspeicher: ${path.basename(file)} ` + `(${(cached.length / 1e6).toFixed(0)} MB)`); return cached; } process.stdout.write(` lade ${path.basename(file)} ... `); const res = await fetch(url, { redirect: 'follow' }); if (!res.ok) throw new Error(`HTTP ${res.status} fuer ${url}`); const buf = Buffer.from(await res.arrayBuffer()); await fs.writeFile(file, buf); console.log(`${(buf.length / 1e6).toFixed(0)} MB`); return buf; } /** Alle Dateien unterhalb `dir`, relativ, ohne die ausgeschlossenen. */ async function walk(dir: string, base = dir): Promise { const out: string[] = []; for (const e of await fs.readdir(dir, { withFileTypes: true })) { const full = path.join(dir, e.name); if (e.isDirectory()) out.push(...await walk(full, base)); else out.push(path.relative(base, full).replace(/\\/g, '/')); } return out; } // ---------------------------------------------------------------- Bau async function main(): Promise { const args = process.argv.slice(2); const arg = (n: string) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; }; const version = arg('--version') ?? new Date().toISOString().slice(0, 10).replace(/-/g, '.'); const outDir = path.resolve(repo, arg('--out') ?? 'dist'); console.log(`abdulive ${version} fuer Windows x64\n`); const files: Array<{ name: string; data: Buffer }> = []; const used: Record = {}; // --- mitgelieferte Programme for (const c of COMPONENTS) { console.log(`${c.name}:`); const url = await c.url(); const zip = await download(url); used[c.name] = { archiv: path.basename(new URL(url).pathname), sha256: sha256(zip) }; let taken = 0; for (const entry of readZip(zip)) { if (entry.isDirectory) continue; const target = c.pick(entry.name); if (!target) continue; files.push({ name: target, data: entry.data() }); taken++; } if (!taken) throw new Error(`${c.name}: keine Datei uebernommen - hat sich der Aufbau geaendert?`); console.log(` ${taken} Datei(en) uebernommen`); } // --- Anwendung: Quellen, ohne Tests console.log('\nAnwendung:'); for (const dir of ['packages', 'apps']) { for (const rel of await walk(path.join(repo, dir))) { if (rel.endsWith('.test.ts')) continue; if (!/\.(ts|sql|json)$/.test(rel)) continue; files.push({ name: `app/${dir}/${rel}`, data: await fs.readFile(path.join(repo, dir, rel)) }); } } // Die "imports"-Map darin loest #core/... auf - ohne sie startet nichts. files.push({ name: 'app/package.json', data: await fs.readFile(path.join(repo, 'package.json')) }); for (const f of ['config.example.yaml', 'layout.yaml']) { files.push({ name: `app/config/${f}`, data: await fs.readFile(path.join(repo, 'config', f)) }); } for (const rel of await walk(path.join(repo, 'assets', 'fonts'))) { files.push({ name: `fonts/${rel}`, data: await fs.readFile(path.join(repo, 'assets/fonts', rel)) }); } for (const f of ['launcher.ps1', 'install.ps1', 'backup.ps1']) { files.push({ name: f, data: await fs.readFile(path.join(repo, 'scripts', f)) }); } console.log(` ${files.filter((f) => f.name.startsWith('app/')).length} Quelldateien`); // --- Kopfdaten const manifestInPackage = { version, gebaut: new Date().toISOString(), node: NODE_VERSION, bausteine: used, }; files.push({ name: 'version.json', data: Buffer.from(JSON.stringify(manifestInPackage, null, 2) + '\n'), }); // NODE_OPTIONS setzen: node:sqlite meldet sich sonst bei jedem Aufruf mit // einer ExperimentalWarning auf stderr. Im Container stand das in der // Dockerfile - beim Umzug ging es verloren, und unter PowerShell wird aus // stderr schnell ein Abbruch (siehe launcher.ps1). files.push({ name: 'abdulive.cmd', data: Buffer.from([ '@echo off', 'set "NODE_OPTIONS=--disable-warning=ExperimentalWarning"', '"%~dp0node.exe" "%~dp0app\\apps\\cli\\main.ts" %*', '', ].join('\r\n')), }); // --- packen await fs.mkdir(outDir, { recursive: true }); const zipName = `abdulive-${version}-win-x64.zip`; const zip = writeZip(files); await fs.writeFile(path.join(outDir, zipName), zip); // Das Manifest, das der Updater abfragt. Die Adresse traegt die // Veroeffentlichung ein - hier steht nur, wie die Datei heisst. await fs.writeFile(path.join(outDir, 'abdulive.json'), JSON.stringify({ version, datei: zipName, groesse: zip.length, sha256: sha256(zip), veroeffentlicht: new Date().toISOString(), }, null, 2) + '\n'); const roh = files.reduce((n, f) => n + f.data.length, 0); console.log(`\nFertig: ${path.join(outDir, zipName)}`); console.log(` ${files.length} Dateien, ${(roh / 1e6).toFixed(0)} MB roh, ` + `${(zip.length / 1e6).toFixed(0)} MB gepackt`); console.log(` sha256 ${sha256(zip)}`); console.log('\nDas Sprachmodell (ca. 465 MB) ist nicht dabei - es wird bei der'); console.log('Einrichtung geladen (`abdulive fetch-model`).'); } await main();