#!/usr/bin/env node /** * Refresh the vai-avatar product mirror inside the Space repo, build, and commit. * * The Space is `sdk: static`: there is no server that builds at request time, * so the ONLY way a vai-avatar change reaches the browser is if the bytes under * dist/ are regenerated and committed. That is this script's reason to exist. * * Intended to run from an HF scheduled job: * * npm run refresh:catalogue * * It is deliberately idempotent: if vai-avatar has not changed, nothing is * committed or pushed, so the Space is not rebuilt pointlessly. * * Failure policy: every step is checked, and a failure aborts BEFORE pushing. * The previously committed mirror therefore keeps serving. A half-written or * empty catalogue is far worse than a slightly stale one. */ import { execFileSync } from 'node:child_process'; import { closeSync, existsSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from 'node:fs'; const REPO = process.env.SPACE_REPO || 'bep40/semantic-image-field'; const CLONE = process.env.CLONE_DIR || '/tmp/space'; /** * The bundle names vite.config.js is configured to emit. * * Kept in step with `build.rollupOptions.output`, where entryFileNames and * assetFileNames both produce "index". The build is what makes them exist, which * is why the swap below happens only after it has run. */ const STABLE_BUNDLE = 'assets/index.js'; const STABLE_STYLE = 'assets/app.css'; /** The two mirrored files, in both the source and the served location. */ const MIRROR = ['products.json', 'promos.json']; /** * Everything this script is allowed to commit. * * An explicit path list rather than `git add -A`, and that is the single most * important safety property of this file. `.gitattributes` routes * products-index.json, products-gallery.json and dist/products-gallery.json * through git-lfs. The clone is a shallow `--depth 1` fetch; if the environment * has no git-lfs binary the smudge filter never runs and those files check out * as ~130-byte pointer text. `git add -A` would then stage the pointers and the * push would replace committed LFS objects with text — permanent, silent data * loss on the Hub, committed by a script that reported success. * * None of the paths below can be an LFS pointer, so a missing git-lfs no longer * has to be fatal: it only means the build output is unverifiable, not that the * mirror is unsafe to publish. * * `dist/catalogue` is here and not merely `public/catalogue` because of WHAT THE * BROWSER ACTUALLY FETCHES. Vite copies public/ into outDir/, so the bytes are * served from dist/catalogue/products.json — and that is the only place they * exist: there is no /catalogue/ at the Space root, because the Space serves * its root index.html rather than app.html, so nothing lands outside dist/. * * catalogue.js candidateURLs() resolves against the bundle at dist/assets/, and * the `../catalogue/…` candidate lands exactly on dist/catalogue/. (The * `../../…` candidate that precedes it lands on /catalogue/, which does not * exist, so it 404s first — a wasted request, then the dist/ form answers. The * ordering is worth knowing but not worth depending on.) * * Committing only the source copy would stage the new mirror, commit the new * bundle, print REFRESH_OK — and still 404 at runtime, because the copy that is * served was never pushed. That is a silent failure with no error anywhere, and * it is exactly the "sửa sp không đồng bộ" symptom. */ const COMMITTABLE = ['index.html', 'public/catalogue', 'dist/catalogue', 'dist/assets', 'dist/share', 'public/share']; const ok = message => console.log(`[refresh] ${message}`); const warn = message => console.warn(`[refresh] WARNING: ${message}`); /** Run a command in the clone. Throws with the captured stderr attached. */ function run(command, args, { cwd = CLONE } = {}) { return execFileSync(command, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024, }); } const shell = (script, cwd = CLONE) => run('bash', ['-lc', script], { cwd }); /** * The token goes into an Authorization header rather than the remote URL. * Embedding it in https://user:TOKEN@huggingface.co persists it in * .git/config and can leak it into reflogs and trace output. */ function git(...args) { return run( 'git', ['-c', `http.extraheader=Authorization: Bearer ${process.env.HF_TOKEN}`, ...args], ); } /** * Is git-lfs installed at all? (The smudge filter can only exist after * `git lfs install`, so availability is checked on the binary first, then * the clone-local filter is (re)wired with `git lfs install --local`.) */ function hasGitLfsBinary() { try { return Boolean(shell('git lfs version 2>/dev/null || true').trim()); } catch { return false; } } /** Is git-lfs BOTH installed AND wired as a smudge filter for this clone? */ function hasGitLfs() { try { if (!hasGitLfsBinary()) return false; return Boolean(shell('git config --get filter.lfs.smudge || true').trim()); } catch { return false; } } /** Is this a git-lfs pointer rather than real content? */ function isLfsPointer(path) { let handle; try { handle = openSync(path, 'r'); const buffer = Buffer.alloc(256); const read = readSync(handle, buffer, 0, 256, 0); return buffer.subarray(0, read).toString('utf8') .startsWith('version https://git-lfs.github.com/spec/v1'); } catch { return false; } finally { if (handle !== undefined) { try { closeSync(handle); } catch { /* already gone */ } } } } /** Size of `relative`, or null if it is missing, tiny, or an LFS pointer. */ function realSize(relative) { const path = `${CLONE}/${relative}`; if (!existsSync(path)) return null; const size = statSync(path).size; if (size < 256 || isLfsPointer(path)) return null; return size; } /** * Fail unless `relative` (resolved against the clone root) holds real content. * * `relative` must be a path from CLONE, not from dist/ — passing a bare filename * here silently checks the wrong location, which is how a missing committed data * file can pass as present. */ function requireRealFile(relative, { why }) { const path = `${CLONE}/${relative}`; if (!existsSync(path)) throw new Error(`${relative} is missing — ${why}`); if (realSize(relative) === null) { throw new Error( `${relative} is a git-lfs pointer or a truncated file, not real content. ${why}`, ); } } /** Parse a mirror file and report how many records it holds. */ function recordCount(relative) { const parsed = JSON.parse(readFileSync(`${CLONE}/${relative}`, 'utf8')); if (Array.isArray(parsed)) return parsed.length; return Object.keys(parsed?.productEdits ?? {}).length; } /** True when index.html already names both stable assets. */ function indexReferencesStable(html) { return /\.\/dist\/assets\/index\.js\b/.test(html) && /\.\/dist\/assets\/app\.css\b/.test(html); } /** * Point index.html at the freshly built bundle. * * This is load-bearing, not housekeeping. index.html is NOT a Vite input (the * input is app.html), so `vite build` never rewrites it — yet the build emits * the STABLE names while index.html still names a hashed bundle from an earlier * build. Those hashed files survive, because emptyOutDir is false precisely so * the build cannot delete committed data. * * The result was a refresh that could not fail loudly: the new bundle was * committed, the mirror was committed, the stale-but-present asset reference * passed verification, and the script reported REFRESH_OK — while the Space * kept serving the pre-fix JavaScript. The catalogue would update and the code * reading it would not, which looks exactly like "sửa sp không đồng bộ" and * survives every rebuild. * * So the swap is part of the refresh. It is idempotent: once index.html names * the stable files, the regex matches them, the write is a no-op, and git sees * no change. */ function repointIndexHtml() { const path = `${CLONE}/index.html`; const html = readFileSync(path, 'utf8'); // Already correct: leave the file untouched so git sees no change. if (indexReferencesStable(html)) return false; // Only the asset reference is touched. Matching on the full "./dist/assets/" // prefix means a hand-added link to something else in that directory (a // vendored worker, say) is left alone rather than silently renamed. let next = html.replace( /(\.\/dist\/assets\/)index-[A-Za-z0-9_-]+\.js\b/g, `$1${STABLE_BUNDLE.slice('assets/'.length)}`, ); next = next.replace( /(\.\/dist\/assets\/)index-[A-Za-z0-9_-]+\.css\b/g, `$1${STABLE_STYLE.slice('assets/'.length)}`, ); // The rewrite matched nothing AND index.html is not already correct. That is // the case this guard exists for: index.html names its bundle by some other // scheme (a query string, a different directory, a hand-written