333 lines
9.8 KiB
JavaScript
333 lines
9.8 KiB
JavaScript
/**
|
|
* Download presenton-export release (Linux x64) into repo-root `presentation-export/`.
|
|
* Same release host as Electron (`electron/sync_export_runtime.js`); Docker uses this at build time.
|
|
*
|
|
* Version resolution (first match):
|
|
* 1. EXPORT_RUNTIME_VERSION env
|
|
* 2. package.json → presentationExportVersion
|
|
*
|
|
* CLI: --force re-download even if valid runtime already exists
|
|
* --check-only verify index.cjs + converter exist and exit 0/1
|
|
*/
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const https = require("https");
|
|
const http = require("http");
|
|
const { execFileSync } = require("child_process");
|
|
|
|
const repoRoot = path.join(__dirname, "..");
|
|
const targetRoot = path.join(repoRoot, "presentation-export");
|
|
const targetPyDir = path.join(targetRoot, "py");
|
|
const targetIndexJs = path.join(targetRoot, "index.js");
|
|
const targetIndexCjs = path.join(targetRoot, "index.cjs");
|
|
const packageJsonFile = path.join(repoRoot, "package.json");
|
|
const cacheDir = path.join(repoRoot, ".cache", "presentation-export");
|
|
const exportRepoBase =
|
|
"https://github.com/presenton/presenton-export/releases/download";
|
|
const linuxAssetName = "export-Linux-X64.zip";
|
|
|
|
const cliArgs = new Set(process.argv.slice(2));
|
|
const forceDownload = cliArgs.has("--force");
|
|
const checkOnly = cliArgs.has("--check-only");
|
|
|
|
function ensureDir(dirPath) {
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
}
|
|
|
|
function readPinnedVersion() {
|
|
if (!fs.existsSync(packageJsonFile)) {
|
|
throw new Error(
|
|
`Missing ${path.relative(repoRoot, packageJsonFile)}. Add \"presentationExportVersion\": \"vX.Y.Z\".`
|
|
);
|
|
}
|
|
const raw = JSON.parse(fs.readFileSync(packageJsonFile, "utf8"));
|
|
const v = (raw.presentationExportVersion || "").trim();
|
|
if (!v) {
|
|
throw new Error(
|
|
`${path.relative(repoRoot, packageJsonFile)} must set \"presentationExportVersion\" (e.g. \"v0.2.0\").`
|
|
);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
async function getTargetVersion() {
|
|
const fromEnv = (process.env.EXPORT_RUNTIME_VERSION || "").trim();
|
|
if (fromEnv) {
|
|
return fromEnv === "latest" ? await resolveLatestTag() : fromEnv;
|
|
}
|
|
const pinned = readPinnedVersion();
|
|
if (pinned === "latest") {
|
|
return await resolveLatestTag();
|
|
}
|
|
return pinned;
|
|
}
|
|
|
|
function requestJson(url, redirects = 5) {
|
|
return new Promise((resolve, reject) => {
|
|
const client = url.startsWith("https:") ? https : http;
|
|
const req = client.get(
|
|
url,
|
|
{
|
|
headers: {
|
|
"User-Agent": "presenton-presentation-export-sync",
|
|
Accept: "application/vnd.github+json",
|
|
},
|
|
},
|
|
(res) => {
|
|
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
|
if (redirects <= 0) {
|
|
reject(new Error(`Too many redirects for JSON request: ${url}`));
|
|
return;
|
|
}
|
|
requestJson(res.headers.location, redirects - 1).then(resolve).catch(reject);
|
|
return;
|
|
}
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
reject(new Error(`Failed to fetch ${url}. HTTP ${res.statusCode}`));
|
|
return;
|
|
}
|
|
let payload = "";
|
|
res.setEncoding("utf8");
|
|
res.on("data", (chunk) => {
|
|
payload += chunk;
|
|
});
|
|
res.on("end", () => {
|
|
try {
|
|
resolve(JSON.parse(payload));
|
|
} catch (e) {
|
|
reject(new Error(`Invalid JSON from ${url}: ${e.message}`));
|
|
}
|
|
});
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
async function resolveLatestTag() {
|
|
const apiUrl =
|
|
"https://api.github.com/repos/presenton/presenton-export/releases/latest";
|
|
const latest = await requestJson(apiUrl);
|
|
if (!latest.tag_name) {
|
|
throw new Error(`Could not resolve latest tag from ${apiUrl}`);
|
|
}
|
|
return latest.tag_name;
|
|
}
|
|
|
|
function chmodIfPossible(filePath) {
|
|
if (process.platform !== "win32") {
|
|
fs.chmodSync(filePath, 0o755);
|
|
}
|
|
}
|
|
|
|
function getConverterCandidates(baseDir = targetPyDir) {
|
|
return [
|
|
path.join(baseDir, "convert-linux-x64"),
|
|
path.join(baseDir, "convert-linux-amd64"),
|
|
path.join(baseDir, "convert"),
|
|
];
|
|
}
|
|
|
|
function hasRuntimeBundle(baseDir) {
|
|
const indexPath = path.join(baseDir, "index.js");
|
|
if (!fs.existsSync(indexPath)) {
|
|
return false;
|
|
}
|
|
|
|
const pyCandidates = getConverterCandidates(path.join(baseDir, "py"));
|
|
const rootCandidates = getConverterCandidates(baseDir);
|
|
return [...pyCandidates, ...rootCandidates].some((candidate) =>
|
|
fs.existsSync(candidate)
|
|
);
|
|
}
|
|
|
|
function moveFileAtomic(src, dest) {
|
|
try {
|
|
fs.renameSync(src, dest);
|
|
} catch {
|
|
fs.copyFileSync(src, dest);
|
|
fs.rmSync(src, { force: true });
|
|
}
|
|
}
|
|
|
|
function normalizeRuntimeLayout() {
|
|
if (!fs.existsSync(targetRoot)) {
|
|
return;
|
|
}
|
|
|
|
ensureDir(targetPyDir);
|
|
|
|
const rootCandidates = getConverterCandidates(targetRoot);
|
|
for (const sourcePath of rootCandidates) {
|
|
if (!fs.existsSync(sourcePath)) {
|
|
continue;
|
|
}
|
|
|
|
const destinationPath = path.join(targetPyDir, path.basename(sourcePath));
|
|
if (!fs.existsSync(destinationPath)) {
|
|
moveFileAtomic(sourcePath, destinationPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
function ensureCommonJsEntrypoint() {
|
|
if (!fs.existsSync(targetIndexJs)) {
|
|
return { ok: false, reason: `Missing runtime bundle: ${targetIndexJs}` };
|
|
}
|
|
|
|
if (fs.existsSync(targetIndexCjs)) {
|
|
return { ok: true, entrypointPath: targetIndexCjs };
|
|
}
|
|
|
|
try {
|
|
fs.copyFileSync(targetIndexJs, targetIndexCjs);
|
|
return { ok: true, entrypointPath: targetIndexCjs };
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
reason: `Failed to create CommonJS entrypoint ${targetIndexCjs}: ${err.message}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
function validateExistingRuntime() {
|
|
normalizeRuntimeLayout();
|
|
|
|
const entrypoint = ensureCommonJsEntrypoint();
|
|
if (!entrypoint.ok) {
|
|
return { ok: false, reason: entrypoint.reason };
|
|
}
|
|
|
|
const candidates = getConverterCandidates();
|
|
const converterPath = candidates.find((c) => fs.existsSync(c));
|
|
if (!converterPath) {
|
|
return {
|
|
ok: false,
|
|
reason: `No Linux converter binary under ${targetPyDir} or ${targetRoot}.`,
|
|
};
|
|
}
|
|
chmodIfPossible(converterPath);
|
|
return { ok: true, entrypointPath: entrypoint.entrypointPath, converterPath };
|
|
}
|
|
|
|
function downloadFile(url, outputPath, redirects = 5) {
|
|
return new Promise((resolve, reject) => {
|
|
const client = url.startsWith("https:") ? https : http;
|
|
const req = client.get(
|
|
url,
|
|
{
|
|
headers: {
|
|
"User-Agent": "presenton-presentation-export-sync",
|
|
Accept: "application/octet-stream",
|
|
},
|
|
},
|
|
(res) => {
|
|
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
|
if (redirects <= 0) {
|
|
reject(new Error(`Too many redirects while downloading ${url}`));
|
|
return;
|
|
}
|
|
downloadFile(res.headers.location, outputPath, redirects - 1)
|
|
.then(resolve)
|
|
.catch(reject);
|
|
return;
|
|
}
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
reject(new Error(`Failed to download ${url}. HTTP ${res.statusCode}`));
|
|
return;
|
|
}
|
|
ensureDir(path.dirname(outputPath));
|
|
const fileStream = fs.createWriteStream(outputPath);
|
|
res.pipe(fileStream);
|
|
fileStream.on("finish", () => {
|
|
fileStream.close(resolve);
|
|
});
|
|
fileStream.on("error", reject);
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function unzipArchive(zipPath, destDir) {
|
|
ensureDir(destDir);
|
|
execFileSync("unzip", ["-o", zipPath, "-d", destDir], { stdio: "inherit" });
|
|
}
|
|
|
|
function resolveExtractedRoot(extractDir) {
|
|
if (hasRuntimeBundle(extractDir)) {
|
|
return extractDir;
|
|
}
|
|
|
|
const children = fs.readdirSync(extractDir, { withFileTypes: true });
|
|
for (const entry of children) {
|
|
if (!entry.isDirectory()) continue;
|
|
const candidate = path.join(extractDir, entry.name);
|
|
if (hasRuntimeBundle(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
throw new Error(`Unable to locate export runtime root under ${extractDir}`);
|
|
}
|
|
|
|
async function downloadAndInstallRuntime() {
|
|
const tag = await getTargetVersion();
|
|
const downloadUrl = `${exportRepoBase}/${tag}/${linuxAssetName}`;
|
|
|
|
ensureDir(cacheDir);
|
|
const zipPath = path.join(cacheDir, linuxAssetName);
|
|
const extractDir = path.join(cacheDir, `extract-${Date.now()}`);
|
|
|
|
console.log(`[presentation-export] Downloading ${downloadUrl}`);
|
|
await downloadFile(downloadUrl, zipPath);
|
|
|
|
console.log(`[presentation-export] Extracting ${zipPath}`);
|
|
unzipArchive(zipPath, extractDir);
|
|
|
|
const sourceRoot = resolveExtractedRoot(extractDir);
|
|
fs.rmSync(targetRoot, { recursive: true, force: true });
|
|
ensureDir(targetRoot);
|
|
fs.cpSync(sourceRoot, targetRoot, { recursive: true, force: true });
|
|
|
|
fs.rmSync(extractDir, { recursive: true, force: true });
|
|
|
|
return { tag, downloadUrl };
|
|
}
|
|
|
|
async function main() {
|
|
const existing = validateExistingRuntime();
|
|
|
|
if (checkOnly) {
|
|
if (!existing.ok) {
|
|
throw new Error(existing.reason);
|
|
}
|
|
console.log("[presentation-export] OK");
|
|
console.log(` - ${existing.entrypointPath}`);
|
|
console.log(` - ${existing.converterPath}`);
|
|
return;
|
|
}
|
|
|
|
if (existing.ok && !forceDownload) {
|
|
console.log("[presentation-export] Using existing runtime:");
|
|
console.log(` - ${existing.entrypointPath}`);
|
|
console.log(` - ${existing.converterPath}`);
|
|
return;
|
|
}
|
|
|
|
const { tag, downloadUrl } = await downloadAndInstallRuntime();
|
|
const installed = validateExistingRuntime();
|
|
if (!installed.ok) {
|
|
throw new Error(installed.reason);
|
|
}
|
|
|
|
console.log("[presentation-export] Synced successfully:");
|
|
console.log(` - release: ${tag}`);
|
|
console.log(` - url: ${downloadUrl}`);
|
|
console.log(` - ${installed.entrypointPath}`);
|
|
console.log(` - ${installed.converterPath}`);
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error(`[presentation-export] ${err.message}`);
|
|
process.exit(1);
|
|
});
|