mirror of
https://gitlab.com/vyla-entertainment/home.git
synced 2026-11-09 19:57:20 -05:00
612 lines
24 KiB
JavaScript
612 lines
24 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const zlib = require('zlib');
|
|
const https = require('https');
|
|
const AdmZip = require('adm-zip');
|
|
|
|
const SDK_NAME = '@vyla-entertainment/sdk';
|
|
const SDK_LATEST_URL = 'https://registry.npmjs.org/@vyla-entertainment%2Fsdk/latest';
|
|
const ALLOWED_ROOTS = ['frontend', 'player'];
|
|
const MAX_TARBALL = 100 * 1024 * 1024;
|
|
const MAX_FILE = 50 * 1024 * 1024;
|
|
const LIVE_HOSTS = ['gitlab.com'];
|
|
const REPO_API = 'https://gitlab.com/api/v4/projects/vyla-entertainment%2F';
|
|
|
|
const verParts = (v) => String(v || '').replace(/^v/, '').split(/[.\-+]/).map((n) => parseInt(n, 10) || 0);
|
|
const cmpVer = (a, b) => {
|
|
const x = verParts(a);
|
|
const y = verParts(b);
|
|
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) - (y[i] || 0);
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
const sha256Buf = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
|
|
const sha256File = (p) => sha256Buf(fs.readFileSync(p));
|
|
const gitBlobSha = (buf) => crypto.createHash('sha1').update(Buffer.concat([Buffer.from(`blob ${buf.length}\0`), buf])).digest('hex');
|
|
|
|
const hooks = { notice: null };
|
|
|
|
function fetchBuffer(url, { headers = {}, maxBytes, hosts, redirects = 0, retries = 0 }) {
|
|
return new Promise((resolve, reject) => {
|
|
if (redirects > 5) return reject(new Error('Too many redirects'));
|
|
let u;
|
|
try { u = new URL(url); } catch (e) { return reject(new Error('Invalid URL')); }
|
|
if (u.protocol !== 'https:' || !hosts.includes(u.hostname)) {
|
|
return reject(new Error(`Host not allowed: ${u.hostname}`));
|
|
}
|
|
const req = https.get({
|
|
hostname: u.hostname,
|
|
port: 443,
|
|
path: u.pathname + u.search,
|
|
headers: { 'User-Agent': 'VylaHome-Client', ...headers }
|
|
}, (res) => {
|
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
res.resume();
|
|
const next = new URL(res.headers.location, url).toString();
|
|
const keep = new URL(next).hostname === u.hostname ? headers : {};
|
|
fetchBuffer(next, { headers: keep, maxBytes, hosts, redirects: redirects + 1 }).then(resolve, reject);
|
|
return;
|
|
}
|
|
if (res.statusCode === 429 && retries < 4) {
|
|
res.resume();
|
|
const wait = Math.min(parseInt(res.headers['retry-after'], 10) || 5, 30) * 1000;
|
|
if (hooks.notice) hooks.notice(`GitLab rate limit (HTTP 429). Retrying in ${Math.round(wait / 1000)}s (${retries + 1}/4)...`);
|
|
setTimeout(() => {
|
|
fetchBuffer(url, { headers, maxBytes, hosts, redirects, retries: retries + 1 }).then(resolve, reject);
|
|
}, wait);
|
|
return;
|
|
}
|
|
if (res.statusCode !== 200) {
|
|
res.resume();
|
|
reject(new Error(`HTTP ${res.statusCode}`));
|
|
return;
|
|
}
|
|
const chunks = [];
|
|
let size = 0;
|
|
res.on('data', (c) => {
|
|
size += c.length;
|
|
if (size > maxBytes) {
|
|
req.destroy();
|
|
reject(new Error('Response too large'));
|
|
return;
|
|
}
|
|
chunks.push(c);
|
|
});
|
|
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
res.on('error', reject);
|
|
});
|
|
req.on('error', reject);
|
|
req.setTimeout(30000, () => {
|
|
req.destroy();
|
|
reject(new Error('Request timeout'));
|
|
});
|
|
});
|
|
}
|
|
|
|
function safeRel(rel) {
|
|
if (typeof rel !== 'string' || !rel || rel.includes('\0') || rel.includes('\\')) return null;
|
|
const norm = path.posix.normalize(rel);
|
|
if (norm !== rel || norm.startsWith('/') || norm.endsWith('/')) return null;
|
|
const parts = norm.split('/');
|
|
if (parts.length < 2 || !ALLOWED_ROOTS.includes(parts[0])) return null;
|
|
if (parts.some((p) => p === '..' || p === '.' || p === 'node_modules' || p === '.git')) return null;
|
|
return norm;
|
|
}
|
|
|
|
function extractTar(tar, destDir) {
|
|
const root = path.resolve(destDir);
|
|
let offset = 0;
|
|
while (offset + 512 <= tar.length) {
|
|
const header = tar.subarray(offset, offset + 512);
|
|
if (header.every((b) => b === 0)) break;
|
|
let name = header.toString('utf8', 0, 100).replace(/\0.*$/, '');
|
|
const prefix = header.toString('utf8', 345, 500).replace(/\0.*$/, '');
|
|
if (prefix) name = `${prefix}/${name}`;
|
|
const size = parseInt(header.toString('utf8', 124, 136).replace(/\0.*$/, '').trim(), 8) || 0;
|
|
const type = String.fromCharCode(header[156]);
|
|
offset += 512;
|
|
const data = tar.subarray(offset, offset + size);
|
|
offset += Math.ceil(size / 512) * 512;
|
|
if ((type !== '0' && type !== '\0') || !name.startsWith('package/')) continue;
|
|
const rel = path.posix.normalize(name.slice('package/'.length));
|
|
if (!rel || rel.startsWith('..') || rel.startsWith('/') || rel.endsWith('/')) {
|
|
throw new Error(`Unsafe path in tarball: ${name}`);
|
|
}
|
|
const full = path.resolve(root, rel);
|
|
if (!full.startsWith(root + path.sep)) {
|
|
throw new Error(`Unsafe path in tarball: ${name}`);
|
|
}
|
|
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
fs.writeFileSync(full, data);
|
|
}
|
|
}
|
|
|
|
class FileUpdater {
|
|
constructor(appDataPath, updateClient) {
|
|
this.appDataPath = appDataPath;
|
|
this.updateClient = updateClient;
|
|
this.onProgress = null;
|
|
this.statePath = path.join(appDataPath, 'file-state.json');
|
|
}
|
|
|
|
set onNotice(fn) {
|
|
hooks.notice = fn;
|
|
}
|
|
|
|
sdkHeaders() {
|
|
const h = { Accept: 'application/json' };
|
|
if (process.env.NPM_TOKEN) h.Authorization = `Bearer ${process.env.NPM_TOKEN}`;
|
|
return h;
|
|
}
|
|
|
|
liveHeaders(extra = {}) {
|
|
const t = this.updateClient && this.updateClient.token;
|
|
return t ? { ...extra, 'PRIVATE-TOKEN': t } : extra;
|
|
}
|
|
|
|
target(rel) {
|
|
const root = path.resolve(this.appDataPath);
|
|
const full = path.resolve(root, ...rel.split('/'));
|
|
if (!full.startsWith(root + path.sep)) throw new Error(`Path escape: ${rel}`);
|
|
return full;
|
|
}
|
|
|
|
readState() {
|
|
try {
|
|
const s = JSON.parse(fs.readFileSync(this.statePath, 'utf8'));
|
|
return {
|
|
seq: typeof s.seq === 'number' ? s.seq : null,
|
|
base: s.base && typeof s.base === 'object' ? s.base : {},
|
|
remote: s.remote && typeof s.remote === 'object' ? s.remote : {},
|
|
local: s.local && typeof s.local === 'object' ? s.local : {},
|
|
commits: s.commits && typeof s.commits === 'object' ? s.commits : {}
|
|
};
|
|
} catch (e) {
|
|
return { seq: null, base: {}, remote: {}, local: {}, commits: {} };
|
|
}
|
|
}
|
|
|
|
writeState(state) {
|
|
const cur = this.readState();
|
|
const next = {
|
|
seq: state.seq !== undefined ? state.seq : cur.seq,
|
|
base: state.base || cur.base,
|
|
remote: state.remote || cur.remote,
|
|
local: state.local || cur.local,
|
|
commits: state.commits || cur.commits
|
|
};
|
|
fs.mkdirSync(this.appDataPath, { recursive: true });
|
|
const tmp = `${this.statePath}.tmp`;
|
|
fs.writeFileSync(tmp, JSON.stringify(next), 'utf8');
|
|
fs.renameSync(tmp, this.statePath);
|
|
}
|
|
|
|
snapshotBase() {
|
|
const base = {};
|
|
const walk = (dir, rel) => {
|
|
for (const name of fs.readdirSync(dir)) {
|
|
if (name === 'node_modules' || name === '.git') continue;
|
|
const full = path.join(dir, name);
|
|
const r = `${rel}/${name}`;
|
|
const st = fs.lstatSync(full);
|
|
if (st.isDirectory()) walk(full, r);
|
|
else if (st.isFile()) base[r] = sha256File(full);
|
|
}
|
|
};
|
|
for (const root of ALLOWED_ROOTS) {
|
|
const dir = path.join(this.appDataPath, root);
|
|
if (fs.existsSync(dir)) walk(dir, root);
|
|
}
|
|
this.writeState({ seq: this.readState().seq, base, remote: {}, local: {}, commits: {} });
|
|
}
|
|
|
|
zipMatches(zipPath, sha256) {
|
|
try {
|
|
return sha256File(zipPath) === String(sha256).trim().toLowerCase();
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async checkFiles() {
|
|
const versionFile = path.join(this.appDataPath, 'version.json');
|
|
let local;
|
|
try {
|
|
local = JSON.parse(fs.readFileSync(versionFile, 'utf8'));
|
|
} catch (e) {
|
|
return { seq: 0, changes: [] };
|
|
}
|
|
const release = await this.updateClient.checkForUpdates();
|
|
const norm = (v) => String(v || '').replace(/^v/, '').trim();
|
|
if (!release || !release.sha256 || !local.sha256) return { seq: 0, changes: [] };
|
|
if (norm(release.version) !== norm(local.version) || release.sha256 === local.sha256) {
|
|
return { seq: 0, changes: [] };
|
|
}
|
|
|
|
const zipPath = path.join(this.appDataPath, 'pending-ui.zip');
|
|
if (!this.zipMatches(zipPath, release.sha256)) {
|
|
fs.rmSync(zipPath, { force: true });
|
|
await this.updateClient.downloadUpdate(release, zipPath, this.onProgress);
|
|
if (!this.zipMatches(zipPath, release.sha256)) {
|
|
fs.rmSync(zipPath, { force: true });
|
|
throw new Error('Update archive checksum mismatch');
|
|
}
|
|
}
|
|
|
|
const state = this.readState();
|
|
const zip = new AdmZip(zipPath);
|
|
const changes = [];
|
|
for (const entry of zip.getEntries()) {
|
|
if (entry.isDirectory) continue;
|
|
const rel = safeRel(entry.entryName);
|
|
if (!rel) continue;
|
|
const data = entry.getData();
|
|
const hash = sha256Buf(data);
|
|
const full = this.target(rel);
|
|
if (!fs.existsSync(full)) {
|
|
changes.push({ path: rel, sha256: hash, size: data.length, status: 'added' });
|
|
continue;
|
|
}
|
|
const baseline = state.base[rel] || sha256File(full);
|
|
if (baseline !== hash) {
|
|
changes.push({ path: rel, sha256: hash, size: data.length, status: 'modified' });
|
|
}
|
|
}
|
|
|
|
if (changes.length === 0) {
|
|
fs.writeFileSync(versionFile, JSON.stringify({ version: release.version, sha256: release.sha256 }, null, 2), 'utf8');
|
|
fs.rmSync(zipPath, { force: true });
|
|
return { seq: 0, changes: [] };
|
|
}
|
|
|
|
this.pending = { release, zipPath, total: changes.length };
|
|
return { seq: 0, changes };
|
|
}
|
|
|
|
async applyFiles(changes, seq, replacements, onFile) {
|
|
if (!this.pending) throw new Error('No pending update');
|
|
const { release, zipPath, total } = this.pending;
|
|
const zip = new AdmZip(zipPath);
|
|
const staged = [];
|
|
for (let i = 0; i < changes.length; i++) {
|
|
const c = changes[i];
|
|
const entry = zip.getEntry(c.path);
|
|
if (!entry) throw new Error(`Missing in archive: ${c.path}`);
|
|
const data = entry.getData();
|
|
if (sha256Buf(data) !== c.sha256) throw new Error(`Checksum mismatch: ${c.path}`);
|
|
staged.push({ c, data: this.applyReplacements(c.path, data, replacements) });
|
|
if (onFile) onFile(c.path, i + 1, changes.length);
|
|
}
|
|
|
|
const done = [];
|
|
try {
|
|
for (const s of staged) {
|
|
const full = this.target(s.c.path);
|
|
const prev = fs.existsSync(full) ? fs.readFileSync(full) : null;
|
|
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
fs.writeFileSync(`${full}.vyla-tmp`, s.data);
|
|
fs.renameSync(`${full}.vyla-tmp`, full);
|
|
done.push({ full, prev });
|
|
}
|
|
} catch (e) {
|
|
for (const d of done.reverse()) {
|
|
try {
|
|
if (d.prev === null) fs.rmSync(d.full, { force: true });
|
|
else fs.writeFileSync(d.full, d.prev);
|
|
} catch (x) { }
|
|
}
|
|
throw e;
|
|
}
|
|
|
|
const state = this.readState();
|
|
for (const s of staged) state.base[s.c.path] = s.c.sha256;
|
|
this.writeState({ seq: state.seq, base: state.base });
|
|
|
|
if (changes.length === total) {
|
|
fs.writeFileSync(
|
|
path.join(this.appDataPath, 'version.json'),
|
|
JSON.stringify({ version: release.version, sha256: release.sha256 }, null, 2),
|
|
'utf8'
|
|
);
|
|
fs.rmSync(zipPath, { force: true });
|
|
this.pending = null;
|
|
}
|
|
}
|
|
|
|
applyReplacements(rel, buf, replacements) {
|
|
const list = (replacements && replacements[rel.split('/')[0]]) || [];
|
|
if (!list.length) return buf;
|
|
let text = buf.toString('utf8');
|
|
let changed = false;
|
|
for (const rep of list) {
|
|
if (rep && rep.find && text.includes(rep.find)) {
|
|
text = text.split(rep.find).join(rep.replace);
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed ? Buffer.from(text, 'utf8') : buf;
|
|
}
|
|
|
|
async loadArchive(root) {
|
|
const buf = await fetchBuffer(`${REPO_API}${root}/repository/archive.zip?sha=main`, { headers: this.liveHeaders(), maxBytes: 600 * 1024 * 1024, hosts: LIVE_HOSTS });
|
|
const zip = new AdmZip(buf);
|
|
const map = new Map();
|
|
for (const entry of zip.getEntries()) {
|
|
if (entry.isDirectory) continue;
|
|
const name = entry.entryName;
|
|
const i = name.indexOf('/');
|
|
if (i < 0) continue;
|
|
map.set(`${root}/${name.slice(i + 1)}`, entry);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
fetchRepoFile(rel) {
|
|
const hit = this.archive && this.archive.get(rel);
|
|
if (hit) return Promise.resolve(hit.getData());
|
|
const parts = rel.split('/');
|
|
const root = parts.shift();
|
|
const file = encodeURIComponent(parts.join('/'));
|
|
return fetchBuffer(`${REPO_API}${root}/repository/files/${file}/raw?ref=main`, { headers: this.liveHeaders(), maxBytes: MAX_FILE, hosts: LIVE_HOSTS });
|
|
}
|
|
|
|
async headCommit(root) {
|
|
try {
|
|
const raw = await fetchBuffer(`${REPO_API}${root}/repository/commits/main`, {
|
|
headers: this.liveHeaders({ Accept: 'application/json' }),
|
|
maxBytes: 1024 * 1024,
|
|
hosts: LIVE_HOSTS,
|
|
retries: 3
|
|
});
|
|
const id = JSON.parse(raw.toString('utf8')).id;
|
|
return /^[0-9a-f]{40}$/.test(id) ? id : null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
localIntact(root, state) {
|
|
const prefix = `${root}/`;
|
|
const keys = Object.keys(state.remote).filter((k) => k.startsWith(prefix));
|
|
if (keys.length === 0) return false;
|
|
for (const rel of keys) {
|
|
try {
|
|
if (sha256File(this.target(rel)) !== state.local[rel]) return false;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async checkRepos() {
|
|
const state = this.readState();
|
|
const changes = [];
|
|
const remoteIds = {};
|
|
const checkedRoots = [];
|
|
const errors = [];
|
|
const verified = { remote: {}, local: {} };
|
|
const commits = {};
|
|
this.archive = new Map();
|
|
for (const root of ALLOWED_ROOTS) {
|
|
const head = await this.headCommit(root);
|
|
if (this.localIntact(root, state) && (!head || head === state.commits[root])) continue;
|
|
let map;
|
|
try {
|
|
map = await this.loadArchive(root);
|
|
} catch (e) {
|
|
errors.push(`${root}: ${e.message}`);
|
|
continue;
|
|
}
|
|
if (map.size === 0) {
|
|
errors.push(`${root}: empty archive`);
|
|
continue;
|
|
}
|
|
checkedRoots.push(root);
|
|
if (head) commits[root] = head;
|
|
for (const [key, entry] of map) {
|
|
const rel = safeRel(key);
|
|
if (!rel) continue;
|
|
const data = entry.getData();
|
|
const id = gitBlobSha(data);
|
|
remoteIds[rel] = id;
|
|
const full = this.target(rel);
|
|
let ok = false;
|
|
let localHash = null;
|
|
if (fs.existsSync(full)) {
|
|
const buf = fs.readFileSync(full);
|
|
localHash = sha256Buf(buf);
|
|
ok = gitBlobSha(buf) === id || (state.remote[rel] === id && state.local[rel] === localHash);
|
|
}
|
|
if (ok) {
|
|
verified.remote[rel] = id;
|
|
verified.local[rel] = localHash;
|
|
} else {
|
|
changes.push({ path: rel, sha256: id, size: data.length });
|
|
this.archive.set(rel, entry);
|
|
}
|
|
}
|
|
}
|
|
if (checkedRoots.length === 0) {
|
|
if (errors.length) throw new Error(errors.join(' | '));
|
|
return { seq: state.seq === null ? 0 : state.seq, changes: [], removed: [], verified, commits };
|
|
}
|
|
const tracked = new Set([...Object.keys(state.base), ...Object.keys(state.remote)]);
|
|
const removed = [...tracked].filter((rel) =>
|
|
checkedRoots.includes(rel.split('/')[0]) &&
|
|
!remoteIds[rel] &&
|
|
safeRel(rel) &&
|
|
fs.existsSync(this.target(rel))
|
|
);
|
|
if (changes.length === 0 && removed.length === 0) {
|
|
for (const k of Object.keys(verified.remote)) {
|
|
state.remote[k] = verified.remote[k];
|
|
state.local[k] = verified.local[k];
|
|
}
|
|
this.writeState({ remote: state.remote, local: state.local, commits: { ...state.commits, ...commits } });
|
|
this.archive = null;
|
|
}
|
|
return { seq: state.seq === null ? 0 : state.seq, changes, removed, verified, commits };
|
|
}
|
|
|
|
async applyLive(info, replacements, onFile) {
|
|
const staged = [];
|
|
const total = info.changes.length;
|
|
let next = 0;
|
|
if (!this.archive) this.archive = new Map();
|
|
let finished = 0;
|
|
let failure = null;
|
|
const worker = async () => {
|
|
while (next < total && !failure) {
|
|
const c = info.changes[next++];
|
|
try {
|
|
const data = await this.fetchRepoFile(c.path);
|
|
if (gitBlobSha(data) !== c.sha256) throw new Error(`Checksum mismatch: ${c.path}`);
|
|
staged.push({ c, data: this.applyReplacements(c.path, data, replacements) });
|
|
finished++;
|
|
if (onFile) onFile(c.path, finished, total);
|
|
} catch (e) {
|
|
failure = e;
|
|
}
|
|
}
|
|
};
|
|
await Promise.all(Array.from({ length: Math.min(4, total) }, worker));
|
|
this.archive = null;
|
|
if (failure) throw failure;
|
|
|
|
const done = [];
|
|
try {
|
|
for (const s of staged) {
|
|
const full = this.target(s.c.path);
|
|
const prev = fs.existsSync(full) ? fs.readFileSync(full) : null;
|
|
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
fs.writeFileSync(`${full}.vyla-tmp`, s.data);
|
|
fs.renameSync(`${full}.vyla-tmp`, full);
|
|
done.push({ full, prev });
|
|
}
|
|
for (const rel of info.removed) {
|
|
const full = this.target(rel);
|
|
if (!fs.existsSync(full)) continue;
|
|
const prev = fs.readFileSync(full);
|
|
fs.rmSync(full, { force: true });
|
|
done.push({ full, prev });
|
|
}
|
|
} catch (e) {
|
|
for (const d of done.reverse()) {
|
|
try {
|
|
if (d.prev === null) fs.rmSync(d.full, { force: true });
|
|
else fs.writeFileSync(d.full, d.prev);
|
|
} catch (x) { }
|
|
}
|
|
throw e;
|
|
}
|
|
|
|
const state = this.readState();
|
|
for (const s of staged) {
|
|
state.base[s.c.path] = s.c.sha256;
|
|
state.remote[s.c.path] = s.c.sha256;
|
|
state.local[s.c.path] = sha256Buf(s.data);
|
|
}
|
|
for (const rel of info.removed) {
|
|
delete state.base[rel];
|
|
delete state.remote[rel];
|
|
delete state.local[rel];
|
|
}
|
|
const v = info.verified || { remote: {}, local: {} };
|
|
for (const k of Object.keys(v.remote)) {
|
|
state.remote[k] = v.remote[k];
|
|
state.local[k] = v.local[k];
|
|
}
|
|
state.commits = { ...state.commits, ...(info.commits || {}) };
|
|
this.writeState({ seq: info.seq, base: state.base, remote: state.remote, local: state.local, commits: state.commits });
|
|
}
|
|
|
|
installedSdk() {
|
|
const roots = [this.appDataPath, path.join(this.appDataPath, 'frontend'), path.join(this.appDataPath, 'player')];
|
|
const found = [];
|
|
for (const root of roots) {
|
|
const dir = path.join(root, 'node_modules', ...SDK_NAME.split('/'));
|
|
try {
|
|
const version = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version;
|
|
if (version) found.push({ dir, version });
|
|
} catch (e) { }
|
|
}
|
|
return found;
|
|
}
|
|
|
|
recoverSdk() {
|
|
for (const root of [this.appDataPath, path.join(this.appDataPath, 'frontend'), path.join(this.appDataPath, 'player')]) {
|
|
const dir = path.join(root, 'node_modules', ...SDK_NAME.split('/'));
|
|
const old = `${dir}.vyla-old`;
|
|
const stage = `${dir}.vyla-new`;
|
|
try {
|
|
if (!fs.existsSync(dir) && fs.existsSync(old)) fs.renameSync(old, dir);
|
|
fs.rmSync(stage, { recursive: true, force: true });
|
|
if (fs.existsSync(dir)) fs.rmSync(old, { recursive: true, force: true });
|
|
} catch (e) { }
|
|
}
|
|
}
|
|
|
|
async checkSdk() {
|
|
const raw = await fetchBuffer(SDK_LATEST_URL, {
|
|
headers: this.sdkHeaders(),
|
|
maxBytes: 5 * 1024 * 1024,
|
|
hosts: ['registry.npmjs.org']
|
|
});
|
|
const meta = JSON.parse(raw.toString('utf8'));
|
|
const integrity = String((meta.dist && meta.dist.integrity) || '').split(' ').find((s) => s.startsWith('sha512-'));
|
|
if (!meta.version || !meta.dist || !meta.dist.tarball || !integrity) {
|
|
throw new Error('Registry response missing version or integrity');
|
|
}
|
|
const installed = this.installedSdk();
|
|
const targets = installed.filter((i) => cmpVer(i.version, meta.version) < 0);
|
|
const current = targets.length ? targets.map((t) => t.version).sort(cmpVer)[0] : meta.version;
|
|
return {
|
|
outdated: targets.length > 0,
|
|
current,
|
|
latest: meta.version,
|
|
tarball: meta.dist.tarball,
|
|
integrity,
|
|
dirs: targets.map((t) => t.dir)
|
|
};
|
|
}
|
|
|
|
async installSdk(info) {
|
|
const tgz = await fetchBuffer(info.tarball, {
|
|
headers: this.sdkHeaders(),
|
|
maxBytes: MAX_TARBALL,
|
|
hosts: ['registry.npmjs.org']
|
|
});
|
|
const digest = `sha512-${crypto.createHash('sha512').update(tgz).digest('base64')}`;
|
|
if (digest !== info.integrity) throw new Error('SDK integrity check failed');
|
|
const tar = zlib.gunzipSync(tgz, { maxOutputLength: 300 * 1024 * 1024 });
|
|
|
|
for (const dir of info.dirs) {
|
|
const stage = `${dir}.vyla-new`;
|
|
const old = `${dir}.vyla-old`;
|
|
fs.rmSync(stage, { recursive: true, force: true });
|
|
fs.rmSync(old, { recursive: true, force: true });
|
|
fs.mkdirSync(stage, { recursive: true });
|
|
extractTar(tar, stage);
|
|
const version = JSON.parse(fs.readFileSync(path.join(stage, 'package.json'), 'utf8')).version;
|
|
if (version !== info.latest) {
|
|
fs.rmSync(stage, { recursive: true, force: true });
|
|
throw new Error('SDK version mismatch in tarball');
|
|
}
|
|
const had = fs.existsSync(dir);
|
|
if (had) fs.renameSync(dir, old);
|
|
try {
|
|
fs.renameSync(stage, dir);
|
|
} catch (e) {
|
|
if (had) fs.renameSync(old, dir);
|
|
throw e;
|
|
}
|
|
fs.rmSync(old, { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = FileUpdater; |