Spaces:
Running
Running
Download scripts/refresh-catalogue.mjs from bep40/VSEARCH: direct link, hf CLI and curl.
- Browser
- Download file 17.5 kB
-
https://huggingface.co/spaces/bep40/VSEARCH/resolve/main/scripts/refresh-catalogue.mjs
- Command line
-
hf download hf://spaces/bep40/VSEARCH/scripts/refresh-catalogue.mjs
-
curl -L -o refresh-catalogue.mjs https://huggingface.co/spaces/bep40/VSEARCH/resolve/main/scripts/refresh-catalogue.mjs
17.5 kB
| /** | |
| * 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 <script>), so | |
| // the regex changes nothing. Treating that as "already fine" would publish a | |
| // Space whose HTML points at a file the build never emits — a blank page that | |
| // survives every rebuild, because the stale reference keeps resolving to a | |
| // real, still-present file. | |
| if (next === html) { | |
| throw new Error( | |
| 'index.html names its bundle in a way repointIndexHtml() cannot rewrite — ' | |
| + 'refusing to publish (would blank the Space)', | |
| ); | |
| } | |
| // A partial rewrite is just as broken as none: JS pointing at the stable name | |
| // while the <link> still names a hashed CSS (or the reverse) loads a bundle | |
| // without its stylesheet. | |
| if (!indexReferencesStable(next)) { | |
| throw new Error( | |
| 'only part of index.html could be repointed — it would load the bundle ' | |
| + 'without its stylesheet, or vice versa', | |
| ); | |
| } | |
| // Confirm the build actually produced what we are now pointing at. Doing this | |
| // after the rewrite rather than before would be circular; doing it before is | |
| // what makes the rewrite safe. | |
| for (const asset of [STABLE_BUNDLE, STABLE_STYLE]) { | |
| requireRealFile(`dist/${asset}`, { why: 'the build did not produce it' }); | |
| } | |
| writeFileSync(path, next, 'utf8'); | |
| ok(`index.html repointed to ${STABLE_BUNDLE} + ${STABLE_STYLE}`); | |
| return true; | |
| } | |
| function main() { | |
| if (!process.env.HF_TOKEN) { | |
| throw new Error('HF_TOKEN is required to push the refreshed mirror.'); | |
| } | |
| rmSync(CLONE, { recursive: true, force: true }); | |
| run('mkdir', ['-p', CLONE], { cwd: '/' }); | |
| git('init', '-q'); | |
| git('remote', 'add', 'origin', `https://huggingface.co/spaces/${REPO}`); | |
| git('fetch', '-q', '--depth', '1', 'origin', 'main'); | |
| git('checkout', '-q', '-B', 'main', 'FETCH_HEAD'); | |
| ok(`cloned ${REPO}`); | |
| // The og-image is LFS-tracked, and the pre-receive hook rejects raw binaries, | |
| // so the clone MUST be able to smudge/clean via git-lfs. `git lfs install | |
| // --local` inside the clone (a) loads the repository-local filters and (b) | |
| // fails visibly when git-lfs is not installed, instead of silently committing | |
| // a raw PNG pointer that a later push gets rejected for. | |
| if (hasGitLfsBinary()) { | |
| shell('git lfs install --local'); | |
| if (!hasGitLfs()) { | |
| throw new Error('git lfs install --local did not wire the smudge filter'); | |
| } | |
| ok('git-lfs wired for this clone'); | |
| } else { | |
| throw new Error('git-lfs is required (og-image.png is LFS-tracked) — install git-lfs in the job'); | |
| } | |
| // Informational only. COMMITTABLE excludes every LFS-tracked path, so a | |
| // missing git-lfs cannot lead to pointers being committed; it just means the | |
| // committed dist data cannot be verified from this clone. | |
| const lfs = hasGitLfs(); | |
| if (!lfs) { | |
| warn('git-lfs unavailable — dist data will be left unstaged and unverified (safe: it is LFS-tracked)'); | |
| } | |
| shell('set -euo pipefail; npm install --silent --no-audit --no-fund'); | |
| ok('dependencies installed'); | |
| // Copies vai-avatar's live product sources into public/catalogue/. | |
| shell('set -euo pipefail; node scripts/sync-catalogue.mjs'); | |
| // Regenerate the social-share thumbnail (12 newest images) BEFORE the build, | |
| // so Vite copies public/share/ into dist/share/ and the committed og-image | |
| // is refreshed on every sync. Non-fatal: the previous thumbnail keeps | |
| // serving if the Hub is unreachable, but a HARD failure is not silent. | |
| shell('set -euo pipefail; python3 scripts/gen-thumbnail.py'); | |
| for (const share of ['public/share/og-image.png', 'dist/share/og-image.png']) { | |
| requireRealFile(share, { why: 'the social-share thumbnail would be missing' }); | |
| } | |
| ok('social-share thumbnail regenerated'); | |
| // Guard before building: an empty or truncated mirror must never ship. | |
| for (const file of MIRROR) { | |
| requireRealFile(`public/catalogue/${file}`, { why: 'the mirror would ship empty' }); | |
| const count = recordCount(`public/catalogue/${file}`); | |
| if (!count) throw new Error(`public/catalogue/${file} is empty — refusing to publish`); | |
| ok(`public/catalogue/${file}: ${count} entries`); | |
| } | |
| shell('set -euo pipefail; npm run build'); | |
| requireRealFile(`dist/${STABLE_BUNDLE}`, { why: 'the build did not produce it' }); | |
| ok('built'); | |
| /** | |
| * The build copies public/ into dist/, and that copy is what the browser | |
| * fetches. Checked AFTER the build: if Vite's publicDir handling, outDir or a | |
| * plugin config ever changed, this is the point where it surfaces — as a loud | |
| * abort before any push, instead of a Space whose product panel 404s while | |
| * the refresh reports success. | |
| * | |
| * The counts are compared too, not just presence. A stale dist/catalogue left | |
| * over from an earlier build is byte-valid and non-empty, so presence alone | |
| * would happily pass while the Space serves products the sync just replaced. | |
| */ | |
| for (const file of MIRROR) { | |
| const served = `dist/catalogue/${file}`; | |
| requireRealFile(served, { | |
| why: 'the build did not copy public/ into dist/, so the browser would 404', | |
| }); | |
| const source = recordCount(`public/catalogue/${file}`); | |
| const built = recordCount(served); | |
| if (source !== built) { | |
| throw new Error(`${served} has ${built} entries but the mirror has ${source} — the served copy is stale`); | |
| } | |
| ok(`served mirror verified: dist/catalogue/${file} (${built} entries)`); | |
| } | |
| // After the build, so the stable names are known to exist. | |
| repointIndexHtml(); | |
| // Verify the served page BEFORE committing, and only AFTER repointing — the | |
| // original order checked the stale reference against the stale file and passed. | |
| const html = readFileSync(`${CLONE}/index.html`, 'utf8'); | |
| // Query strings (`?v=...`) are cache busters, not part of the filename — | |
| // strip them so the existence check tests the real file on disk. | |
| const referenced = [...html.matchAll(/(?:src|href)="\.\/dist\/([^"]+)"/g)] | |
| .map(match => match[1].split('?')[0]); | |
| if (!referenced.length) { | |
| throw new Error('index.html references no dist/assets bundle — refusing to publish'); | |
| } | |
| for (const name of referenced) { | |
| requireRealFile(`dist/${name}`, { why: 'index.html points at it' }); | |
| } | |
| ok(`index.html references verified: ${referenced.join(', ')}`); | |
| // Confirm the build did not delete the committed data under dist/. | |
| // vite.config.js sets emptyOutDir: false for exactly this reason; a revert of | |
| // that setting would delete ~6,400 files with no error anywhere, and the app | |
| // would keep working because src/ reads embeddings from the dataset repo and | |
| // products from the mirror. | |
| if (lfs) { | |
| if (!existsSync(`${CLONE}/dist/prodimgs`)) { | |
| throw new Error('dist/prodimgs is missing — the build deleted committed data; check emptyOutDir'); | |
| } | |
| for (const file of ['dist/products-gallery.json', 'dist/products-index.json']) { | |
| requireRealFile(file, { why: 'the build deleted committed data, or it is an LFS pointer' }); | |
| } | |
| ok('committed dist data intact'); | |
| } | |
| // Superseded hashed bundles are inert — index.html names exactly one — but they | |
| // accumulate on every build. Left alone on purpose: pruning them is a separate, | |
| // reviewable change, not something a sync job should do on its own. | |
| // Explicit paths, never `git add -A` (see COMMITTABLE). Whether a commit | |
| // happened must come from the commit step itself: testing it afterwards with | |
| // `git diff --cached --quiet` would always report clean, because a successful | |
| // commit clears the staging area — the push would then be skipped and the | |
| // refresh would silently do nothing. | |
| const commit = shell(` | |
| set -euo pipefail | |
| git config user.name "semantic-image-field refresh" | |
| git config user.email "refresh@hf.space" | |
| git add -- ${COMMITTABLE.map(p => `'${p}'`).join(' ')} | |
| if git diff --cached --quiet; then | |
| echo NOCHANGE | |
| else | |
| git commit -q -m "chore: refresh vai-avatar catalogue mirror" | |
| echo COMMITTED | |
| fi | |
| `).trim(); | |
| if (commit.includes('COMMITTED')) { | |
| git('push', '-q', 'origin', 'main'); | |
| ok('pushed — the static Space will rebuild'); | |
| } else { | |
| const head = shell('git rev-parse --short HEAD').trim(); | |
| ok(`catalogue unchanged (HEAD ${head})`); | |
| } | |
| console.log('REFRESH_OK'); | |
| } | |
| try { | |
| main(); | |
| } catch (error) { | |
| const detail = (error.stderr && String(error.stderr).trim()) || error.message; | |
| console.error(`REFRESH_FAILED: ${detail}`); | |
| process.exitCode = 1; | |
| } |