/* * publish-update.cjs — put the freshly built installer where the app's updater can find it, and * write the latest.json manifest it reads. Run after a build: * * npm run release (build + publish) * npm run publish-update (publish the newest existing build) * npm run publish-update -- "" (publish to a specific destination) * npm run publish-update -- "" "Notes about this version" * * A destination is either: * * a folder or network share \\SERVER\share\bible-app, D:\Builds -> the files are copied there * an http(s) URL http://gitea:3000/api/packages/... -> uploaded to Gitea * * The URL form targets a Gitea *generic package registry*, not a release: Gitea caps release * attachments (100 MB here, and .exe is not an allowed type) while the package registry takes the * ~750 MB installer and serves it to anonymous readers, which is what the updater needs — it sends * no credentials. Whatever destination you publish to is the exact string that goes in the app's * Settings > Updates > "Update folder or address". * * Destination order: CLI arg -> BIBLEAPP_UPDATE_DIR env -> package.json bibleApp.updateTarget * -> the folder configured in the app (Notes/update-config.json) -> dist-installer/publish * * Gitea auth: GITEA_TOKEN env, else the macOS keychain item the gitea tooling already uses * (service "gitea-mcp"). The token is never written to disk. */ const fs = require('fs'); const path = require('path'); const os = require('os'); const crypto = require('crypto'); const http = require('http'); const https = require('https'); const { execFileSync } = require('child_process'); // Fingerprint a file so a truncated or interrupted copy can never be mistaken for a good build. function sha256(file) { return new Promise((resolve, reject) => { const h = crypto.createHash('sha256'); const s = fs.createReadStream(file); s.on('data', c => h.update(c)); s.on('error', reject); s.on('end', () => resolve(h.digest('hex'))); }); } const ROOT = path.join(__dirname, '..'); const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8')); const version = pkg.version; function appNotesDir() { // Mirrors the app's user-content location so we can reuse the configured update folder. if (process.platform === 'darwin') { return path.join(os.homedir(), 'Library', 'Application Support', 'Bible App', 'user-content', 'Notes'); } const base = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); return path.join(base, 'Bible App', 'user-content', 'Notes'); } function configuredDest() { try { const p = path.join(appNotesDir(), 'update-config.json'); if (fs.existsSync(p)) { const cfg = JSON.parse(fs.readFileSync(p, 'utf-8')); const s = String(cfg.source || '').trim(); if (s) return s; // a URL is publishable now too (Gitea), not just a folder } } catch (_) {} return ''; } const argDest = process.argv[2] && !process.argv[2].startsWith('--') ? process.argv[2] : ''; const notes = process.argv[3] || ''; // The church's canonical target, tracked in git so every clone publishes to the same place. const repoTarget = (pkg.bibleApp && typeof pkg.bibleApp.updateTarget === 'string') ? pkg.bibleApp.updateTarget.trim() : ''; const dest = argDest || process.env.BIBLEAPP_UPDATE_DIR || repoTarget || configuredDest() || path.join(ROOT, 'dist-installer', 'publish'); const isUrlDest = /^https?:\/\//i.test(dest); const distDir = path.join(ROOT, 'dist-installer'); if (!fs.existsSync(distDir)) { console.error('No dist-installer folder — run the build first (npm run build:win).'); process.exit(1); } // Prefer the installer matching package.json's version; else the newest .exe in dist-installer. const exact = fs.readdirSync(distDir).find(f => f.endsWith('.exe') && f.includes(version)); let installer = exact; if (!installer) { const exes = fs.readdirSync(distDir) .filter(f => f.toLowerCase().endsWith('.exe')) .map(f => ({ f, t: fs.statSync(path.join(distDir, f)).mtimeMs })) .sort((a, b) => b.t - a.t); if (!exes.length) { console.error('No installer (.exe) found in dist-installer.'); process.exit(1); } installer = exes[0].f; console.warn(`! No installer named for v${version}; publishing newest: ${installer}`); } const srcPath = path.join(distDir, installer); const sizeMB = (fs.statSync(srcPath).size / 1048576).toFixed(0); /* ---- Gitea generic package registry -------------------------------------------------------- */ function giteaToken() { const env = (process.env.GITEA_TOKEN || process.env.GITEA_ACCESS_TOKEN || '').trim(); if (env) return env; if (process.platform === 'darwin') { // Same keychain item the gitea MCP launcher reads, so there is one token to rotate, not two. try { const account = process.env.GITEA_KEYCHAIN_ACCOUNT || os.userInfo().username; return execFileSync('/usr/bin/security', ['find-generic-password', '-s', 'gitea-mcp', '-a', account, '-w'], { encoding: 'utf-8' }).trim(); } catch (_) {} } return ''; } // One request helper for PUT (upload) and DELETE. Streams the body so a 750 MB installer never // has to sit in memory, and reports progress because the upload is the slow part of a release. function giteaRequest(method, url, token, filePath) { return new Promise((resolve, reject) => { const u = new URL(url); const mod = u.protocol === 'https:' ? https : http; const headers = { Authorization: 'token ' + token }; let total = 0; if (filePath) { total = fs.statSync(filePath).size; headers['Content-Length'] = total; headers['Content-Type'] = 'application/octet-stream'; } const req = mod.request(url, { method, headers }, (res) => { let body = ''; res.on('data', c => { body += c.toString('utf-8'); }); res.on('end', () => resolve({ status: res.statusCode || 0, body })); }); req.on('error', reject); if (filePath) { let sent = 0, lastPct = -1; const rs = fs.createReadStream(filePath); rs.on('data', (c) => { sent += c.length; const pct = total ? Math.floor((sent / total) * 100) : 0; if (pct !== lastPct && pct % 5 === 0) { lastPct = pct; process.stdout.write(`\r uploading… ${pct}% `); } }); rs.on('error', reject); rs.pipe(req); } else { req.end(); } }); } // Gitea refuses a PUT onto a filename that already exists in the package version, so each file is // removed first. A 404 here just means "nothing published yet", which is the normal first run. async function giteaPut(baseUrl, token, filename, filePath, label) { const target = baseUrl.replace(/\/+$/, '') + '/' + encodeURIComponent(filename); const del = await giteaRequest('DELETE', target, token, null); if (del.status !== 204 && del.status !== 404) { console.warn(`! could not clear the previous ${label} (HTTP ${del.status}) — continuing`); } const put = await giteaRequest('PUT', target, token, filePath); if (put.status !== 201 && put.status !== 200) { throw new Error(`upload of ${label} failed: HTTP ${put.status} ${put.body.slice(0, 200)}`); } process.stdout.write('\r' + ' '.repeat(28) + '\r'); return target; } // A new release uploads its installer under a NEW filename, so the superseded one is still sitting // in the package version taking ~750 MB. Clear the stale ones out once the new manifest is live — // never before, so a TV checking mid-publish always finds the installer its manifest names. // One previous build is kept as a hand-rollback copy; anything older goes. async function giteaPrune(baseUrl, token, keepFile, keepPrevious = 1) { const u = new URL(baseUrl.replace(/\/+$/, '')); // .../api/packages//generic// -> .../api/v1/packages//generic///files const parts = u.pathname.split('/').filter(Boolean); // api, packages, owner, generic, name, version const i = parts.indexOf('packages'); if (i === -1 || parts.length < i + 5) return; const [owner, type, name, ver] = parts.slice(i + 1, i + 5); const listUrl = `${u.origin}/api/v1/packages/${owner}/${type}/${name}/${ver}/files`; const res = await giteaRequest('GET', listUrl, token, null); if (res.status !== 200) return; // listing is a nicety, not a gate let files; try { files = JSON.parse(res.body); } catch (_) { return; } if (!Array.isArray(files)) return; // Rank by the version in the filename, not by created_at: this server returns that field empty, // which would leave every build "equally old" and quietly retain the OLDEST one as the rollback // copy instead of the newest. const verOf = (name) => { const m = String(name).match(/(\d+(?:\.\d+)+)/); return m ? m[1].split('.').map(n => parseInt(n, 10) || 0) : []; }; const newerFirst = (a, b) => { const pa = verOf(a.name), pb = verOf(b.name); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const x = pa[i] || 0, y = pb[i] || 0; if (x !== y) return y - x; } return new Date(b.created_at || 0) - new Date(a.created_at || 0); }; const stale = files .filter(f => f && typeof f.name === 'string' && f.name.toLowerCase().endsWith('.exe') && f.name !== keepFile) .sort(newerFirst) .slice(keepPrevious); for (const f of stale) { const del = await giteaRequest('DELETE', baseUrl.replace(/\/+$/, '') + '/' + encodeURIComponent(f.name), token, null); if (del.status === 204) console.log(` removed old build: ${f.name}`); } } /* -------------------------------------------------------------------------------------------- */ (async () => { console.log(`Publishing v${version}`); console.log(` installer : ${installer} (${sizeMB} MB)`); console.log(` to : ${dest}`); const installerSize = fs.statSync(srcPath).size; console.log(' hashing installer (integrity check)…'); const installerHash = await sha256(srcPath); const manifest = { version, file: installer, size: installerSize, sha256: installerHash, notes: notes || `Bible App ${version}`, date: new Date().toISOString().slice(0, 10) }; if (isUrlDest) { const token = giteaToken(); if (!token) { console.error('No Gitea token. Set GITEA_TOKEN, or store one in the macOS keychain:'); console.error(' security add-generic-password -s gitea-mcp -a "$USER" -w '); process.exit(1); } const manifestPath = path.join(os.tmpdir(), 'bibleapp-latest.json'); fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n'); // The installer goes up first: until latest.json names it, no app can act on a half-published // release. Publishing the manifest last is what makes the switch atomic from the TV's point of view. console.log(` uploading installer (${sizeMB} MB)…`); await giteaPut(dest, token, installer, srcPath, 'installer'); console.log(' uploading latest.json…'); await giteaPut(dest, token, 'latest.json', manifestPath, 'manifest'); try { fs.unlinkSync(manifestPath); } catch (_) {} try { await giteaPrune(dest, token, installer); } catch (e) { console.warn('! could not prune old builds:', e.message); } console.log(` sha256 : ${installerHash}`); console.log('\nDone. The app will offer this version from Settings > Updates.'); console.log(`Update source to set on each machine:\n ${dest}`); return; } /* ---- Folder / network-share destination (unchanged behaviour) ------------------------------- */ fs.mkdirSync(dest, { recursive: true }); const destPath = path.join(dest, installer); fs.copyFileSync(srcPath, destPath); /* * The AI model does NOT need to go in the update folder any more: a new machine gets it from the * one-file "Bible App Full Setup" bundle, and machines that already have it keep it (it lives in * ProgramData/user-data, which no installer touches). So updates stay ~750 MB instead of ~2.7 GB. * * We still refresh a model.gguf that is ALREADY published, so an existing Settings > Local AI * install path keeps working; pass --with-model to publish it for the first time. */ let modelPublished = false; try { const modelSrc = path.join(ROOT, 'vendor', 'llama', 'model.gguf'); const modelDest = path.join(dest, 'model.gguf'); const wanted = process.argv.includes('--with-model') || fs.existsSync(modelDest); if (!wanted) { console.log(' AI model : skipped (ships in the full one-file installer; use --with-model to publish it here)'); } else if (fs.existsSync(modelSrc)) { const srcSize = fs.statSync(modelSrc).size; const needCopy = !fs.existsSync(modelDest) || fs.statSync(modelDest).size !== srcSize; if (needCopy) { console.log(` AI model : copying model.gguf (${(srcSize / 1073741824).toFixed(2)} GB) — one time…`); fs.copyFileSync(modelSrc, modelDest); } else { console.log(' AI model : already published (unchanged)'); } modelPublished = true; } else { console.warn('! vendor/llama/model.gguf not found — publish it manually so machines can install the AI model.'); } } catch (e) { console.warn('! Could not publish the AI model:', e.message); } if (modelPublished) { try { const md = path.join(dest, 'model.gguf'); if (fs.existsSync(md)) { manifest.model = 'model.gguf'; manifest.modelSize = fs.statSync(md).size; console.log(' hashing model (integrity check)…'); manifest.modelSha256 = await sha256(md); } } catch (_) {} } fs.writeFileSync(path.join(dest, 'latest.json'), JSON.stringify(manifest, null, 2) + '\n'); // Companion file so a person can verify a hand-copied installer: // Get-FileHash "Bible App Setup x.y.z.exe" -Algorithm SHA256 fs.writeFileSync(path.join(dest, installer + '.sha256'), installerHash + ' ' + installer + '\n'); console.log(` sha256 : ${installerHash}`); // Keep the folder tidy: retain the 3 newest installers alongside the current one. try { const olds = fs.readdirSync(dest) .filter(f => f.toLowerCase().endsWith('.exe') && f !== installer) .map(f => ({ f, t: fs.statSync(path.join(dest, f)).mtimeMs })) .sort((a, b) => b.t - a.t) .slice(3); olds.forEach(o => { try { fs.unlinkSync(path.join(dest, o.f)); console.log(` removed old build: ${o.f}`); } catch (_) {} }); } catch (_) {} console.log('\nDone. The app will offer this version from Settings > Updates.'); console.log(`Manifest: ${path.join(dest, 'latest.json')}`); })().catch(e => { console.error('Publish failed:', e && e.message || e); process.exit(1); });