"use strict";
const https = require("https");
const fs = require("fs");
const path = require("path");
const os = require("os");
const crypto = require("crypto");
const { execFile } = require("child_process");
const PLUGIN_ID = "zcode-token-usage-statusbar@zusage-marketplace";
const STABLE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
const MAX_ASSET = 16 * 1024 * 1024;
const MAX_JSON = 256 * 1024;
const RELEASE_HOSTS = {
github: {
assetUrl: /^https:\/\/github\.com\/([^/]+\/[^/]+)(\/releases\/download\/[^?#]+)$/,
downloadHosts: ["api.github.com", "github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"],
},
gitcode: {
assetUrl: /^https:\/\/gitcode\.com\/([^/]+\/[^/]+)(\/releases\/download\/[^?#]+)$/,
downloadHosts: ["gitcode.com", "file-cdn.gitcode.com"],
},
};
const DOWNLOAD_HOSTS = Object.values(RELEASE_HOSTS).flatMap((host) => host.downloadHosts);
function githubRepository(value) {
if (typeof value !== "string") return null;
const match = /^(?:https:\/\/github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)?([A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*?)(?:\.git)?\/?$/.exec(value);
return match ? match[1] : null;
}
function gitcodeRepository(value) {
if (typeof value !== "string") return null;
const match = /^(?:https:\/\/gitcode\.com\/|ssh:\/\/git@gitcode\.com\/|git@gitcode\.com:)?([A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*?)(?:\.git)?\/?$/.exec(value);
return match ? match[1] : null;
}
function sameRepository(a, b) {
return typeof a === "string" && typeof b === "string" && githubRepository(a) === a && githubRepository(b) === b && a.toLowerCase() === b.toLowerCase();
}
function parseAssetUrl(value) {
if (typeof value !== "string") return null;
for (const [host, profile] of Object.entries(RELEASE_HOSTS)) {
const match = profile.assetUrl.exec(value);
if (match) return { host, repository: match[1], path: match[2] };
}
return null;
}
function sameAssetUrl(actual, expected) {
const a = parseAssetUrl(actual), b = parseAssetUrl(expected);
return !!a && !!b && a.host === b.host && sameRepository(a.repository, b.repository) && a.path === b.path;
}
function readCatalog(root) {
try {
if (fs.existsSync(path.join(root, "marketplaces", ".zusage-marketplace.transaction.json"))) return null;
return JSON.parse(fs.readFileSync(path.join(root, "marketplaces", "zusage-marketplace", "marketplace.json"), "utf8"));
} catch (e) { return null; }
}
function readChannel(home, root = require("./plugin-storage.cjs").resolvePluginStorage(home)) {
const file = path.join(root, "known_marketplaces.json");
const data = JSON.parse(fs.readFileSync(file, "utf8"));
const entries = data && data.marketplaces;
if (!Array.isArray(entries)) throw new Error("插件市场来源记录无效");
const matches = entries.filter((m) => m && (m.id === "zusage-marketplace" || m.name === "zusage-marketplace"));
const market = matches.length === 1 ? matches[0] : null;
if (!market || !market.source || typeof market.source !== "object") throw new Error("未找到 zusage-marketplace 的注册来源");
const source = market.source;
if (source.source === "directory") return { kind: "local", marketplace: market };
const kind = catalogChannel(readCatalog(root)) === "source" ? "market" : "release";
if (source.source === "git") {
const gitcode = gitcodeRepository(source.url);
if (gitcode) return { kind, flavor: "gitcode", repository: gitcode, marketplace: market };
}
const repository = source.source === "github" ? githubRepository(source.repo || source.repository)
: source.source === "git" ? githubRepository(source.url) : null;
if (!repository) throw new Error("远端自动更新仅支持已注册的 GitHub/GitCode 仓库来源");
return { kind, flavor: "github", repository, marketplace: market };
}
* 省略时保持原有资产下载白名单,不放宽任何既有调用面。 */
* 2026-11-04 实测;vnd.github+json 在两家 API 均可用,资产域保持 octet-stream)。 */
function releaseAcceptHeader(hostname) {
return hostname === "api.github.com" ? "application/vnd.github+json"
: hostname === "api.gitcode.com" ? "application/json"
: "application/octet-stream";
}
async function fetchBytes(url, limit, onProgress, allowedHosts) {
let activeRequest, activeResponse, timer, settled = false;
return new Promise((resolve, reject) => {
const finish = (error, bytes) => {
if (settled) return;
settled = true; clearTimeout(timer);
if (error) {
if (activeResponse) activeResponse.destroy();
if (activeRequest) activeRequest.destroy(error);
reject(error);
} else resolve(bytes);
};
const visit = (target, redirects) => {
try {
const parsed = new URL(target);
const allowed = parsed.protocol === "https:" && !parsed.username && !parsed.password && !parsed.port &&
(allowedHosts || DOWNLOAD_HOSTS).includes(parsed.hostname);
if (!allowed) throw new Error("拒绝非 GitHub/GitCode HTTPS 更新地址");
const request = https.get(parsed, { headers: { "User-Agent": "zcode-token-usage-statusbar-release-updater",
"Accept": releaseAcceptHeader(parsed.hostname),
"X-GitHub-Api-Version": "2022-11-28" } }, (response) => {
if (settled) { response.destroy(); return; }
activeResponse = response;
response.on("error", (error) => finish(error));
response.on("aborted", () => finish(new Error("发布资源下载中断")));
if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
response.resume();
if (!response.headers.location || redirects >= 3) { finish(new Error("下载重定向无效")); return; }
try { visit(new URL(response.headers.location, parsed).href, redirects + 1); }
catch (error) { finish(error); }
return;
}
if (response.statusCode !== 200) {
const statusError = new Error(`Release 请求失败 (${response.statusCode})`);
statusError.status = response.statusCode;
finish(statusError); return;
}
const declared = Number(response.headers["content-length"] || 0);
if (!Number.isSafeInteger(declared) || declared < 0 || declared > limit) { finish(new Error("发布资源超过大小上限")); return; }
const chunks = []; let length = 0;
response.on("data", (chunk) => {
if (settled) return;
length += chunk.length;
if (length > limit) finish(new Error("发布资源超过大小上限"));
else {
chunks.push(chunk);
if (typeof onProgress === "function") onProgress(length, declared);
}
});
response.on("end", () => {
if (!response.complete || (declared && declared !== length)) finish(new Error("发布资源下载不完整"));
else finish(null, Buffer.concat(chunks));
});
});
activeRequest = request;
request.setTimeout(30000, () => finish(new Error("Release 请求超时")));
request.on("error", (error) => finish(error));
} catch (error) { finish(error); }
};
timer = setTimeout(() => finish(new Error("Release 请求超过总时限")), 60000);
visit(url, 0);
});
}
function releaseAssetUrl(repository, tag, name) {
return `https://github.com/${repository}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`;
}
function validateRelease(release, repository) {
if (!release || release.draft !== false || release.prerelease !== false || typeof release.tag_name !== "string") return null;
const version = release.tag_name.startsWith("v") ? release.tag_name.slice(1) : "";
if (!STABLE.test(version)) return null;
const names = [`zcode-token-usage-statusbar-v${version}.zip`, "release-manifest.json"];
const assets = Array.isArray(release.assets) ? release.assets : [];
const picked = names.map((name) => assets.filter((a) => a && a.name === name));
if (picked.some((matches) => matches.length !== 1)) return null;
const [archive, manifest] = picked.map((matches) => matches[0]);
for (const asset of [archive, manifest]) {
const limit = asset === archive ? MAX_ASSET : MAX_JSON;
if (asset.state !== "uploaded" || !Number.isSafeInteger(asset.size) || asset.size <= 0 || asset.size > limit ||
!sameAssetUrl(asset.browser_download_url, releaseAssetUrl(repository, release.tag_name, asset.name))) throw new Error("GitHub Release 资产来源或大小无效");
}
return { flavor: "github", version, tag: release.tag_name, repository, archive, manifest,
url: `https://github.com/${repository}/releases/tag/${encodeURIComponent(release.tag_name)}` };
}
async function checkRelease(repository, compareVersions, fetch = fetchBytes) {
if (!githubRepository(repository) || repository !== githubRepository(repository)) throw new Error("GitHub 仓库标识无效");
let best = null;
for (let page = 1; page <= 10; page++) {
const body = await fetch(`https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}`, 4 * 1024 * 1024);
const rows = JSON.parse(body.toString("utf8"));
if (!Array.isArray(rows)) throw new Error("GitHub Release 列表格式无效");
for (const row of rows) {
const candidate = validateRelease(row, repository);
if (candidate && (!best || compareVersions(candidate.version, best.version) > 0)) best = candidate;
}
if (rows.length < 100) {
if (!best) throw new Error("此 GitHub 仓库尚无带完整发布包的正式 Release,不能安装开发分支");
return best;
}
}
throw new Error("GitHub Release 列表超过安全分页上限,无法确定最新正式版本");
}
function githubReleaseFromCatalog(catalog, repository) {
if (!githubRepository(repository) || repository !== githubRepository(repository)) throw new Error("GitHub 仓库标识无效");
const entries = catalog && catalog.plugins;
const matches = Array.isArray(entries) ? entries.filter((p) => p && p.name === "zcode-token-usage-statusbar") : [];
const entry = matches.length === 1 ? matches[0] : null;
const source = entry && entry.source;
if (!catalog || catalog.name !== "zusage-marketplace" || !entry || typeof entry.version !== "string" ||
!STABLE.test(entry.version) || !source || typeof source !== "object" || source.source !== "url" ||
source.type !== "zip" || typeof source.url !== "string" || source.headers !== undefined ||
(source.stripRoot !== undefined && source.stripRoot !== false) ||
source.path !== "plugins/zcode-token-usage-statusbar" ||
typeof source.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(source.sha256)) throw new Error("GitHub 渠道清单格式无效");
const tag = "v" + entry.version;
const archiveName = `zcode-token-usage-statusbar-v${entry.version}.zip`;
const expectedPath = `/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(archiveName)}`;
const parsed = parseAssetUrl(source.url);
if (!parsed || parsed.host !== "github" || !sameRepository(parsed.repository, repository) ||
parsed.path !== expectedPath) throw new Error("GitHub 渠道清单下载源与注册仓库或版本不一致");
return {
flavor: "github", version: entry.version, tag, repository, catalogSha256: source.sha256,
archive: { name: archiveName, size: null, browser_download_url: source.url },
manifest: { name: "release-manifest.json",
browser_download_url: `https://github.com/${repository}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent("release-manifest.json")}` },
url: `https://github.com/${repository}/releases/tag/${encodeURIComponent(tag)}` };
}
function validateGithubCatalogRelease(release) {
if (!release || release.flavor !== "github" || !githubRepository(release.repository) ||
release.repository !== githubRepository(release.repository) || typeof release.version !== "string" ||
!STABLE.test(release.version) || release.tag !== "v" + release.version ||
typeof release.catalogSha256 !== "string" || !/^[a-f0-9]{64}$/.test(release.catalogSha256) || !release.archive ||
release.archive.name !== `zcode-token-usage-statusbar-v${release.version}.zip` ||
release.archive.size !== null) return null;
const archive = parseAssetUrl(release.archive.browser_download_url);
const manifest = parseAssetUrl(release.manifest && release.manifest.browser_download_url);
const downloadPath = `/releases/download/${encodeURIComponent(release.tag)}/`;
let page = null;
try { page = new URL(release.url); } catch (e) { }
if (!archive || archive.host !== "github" || !sameRepository(archive.repository, release.repository) ||
archive.path !== downloadPath + encodeURIComponent(release.archive.name) ||
!manifest || manifest.host !== "github" || !sameRepository(manifest.repository, release.repository) ||
manifest.path !== downloadPath + encodeURIComponent("release-manifest.json") ||
!page || page.protocol !== "https:" || page.hostname !== "github.com" ||
page.pathname !== `/${release.repository}/releases/tag/${encodeURIComponent(release.tag)}`) return null;
return { ...release, version: release.version };
}
function validateManifest(manifest, release) {
const asset = manifest && manifest.asset;
const repositoryOk = sameRepository(manifest.repository, release.repository) ||
(release.flavor === "gitcode" && typeof manifest.repository === "string" &&
githubRepository(manifest.repository) === manifest.repository);
if (!manifest || manifest.schemaVersion !== 1 || !repositoryOk ||
manifest.version !== release.version || manifest.tag !== release.tag || manifest.pluginId !== PLUGIN_ID || !asset ||
asset.name !== release.archive.name || (release.archive.size !== null && asset.size !== release.archive.size) ||
!Number.isSafeInteger(asset.size) || asset.size <= 0 || asset.size > MAX_ASSET ||
typeof asset.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(asset.sha256)) throw new Error("发布 manifest 与正式版本、来源或资源不一致");
return manifest;
}
function unpack(command, args) {
return new Promise((resolve, reject) => execFile(command, args,
{ windowsHide: true, timeout: 30000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
if (error) { reject(new Error(String(stderr || error.message).trim().slice(0, 1000))); return; }
try { resolve(JSON.parse(stdout)); } catch (e) { reject(new Error("发布包校验器未返回有效 JSON")); }
}));
}
async function downloadRelease(release, python, fetch = fetchBytes, runUnpack = unpack, onProgress) {
const report = typeof onProgress === "function" ? onProgress : () => { };
const manifestBody = await fetch(release.manifest.browser_download_url, MAX_JSON);
const manifest = validateManifest(JSON.parse(manifestBody.toString("utf8")), release);
report({ stage: "download", loaded: 0, total: manifest.asset.size });
const bytes = await fetch(release.archive.browser_download_url, MAX_ASSET,
(loaded, total) => report({ stage: "download", loaded, total: total || manifest.asset.size }));
if (bytes.length !== manifest.asset.size || crypto.createHash("sha256").update(bytes).digest("hex") !== manifest.asset.sha256) throw new Error("发布包下载大小或 SHA256 校验失败");
report({ stage: "verify" });
const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "zusage-release-")));
const cleanup = () => fs.rmSync(directory, { recursive: true, force: true });
try {
const archive = path.join(directory, release.archive.name), info = path.join(directory, "release-manifest.json");
const output = path.join(directory, "marketplace");
fs.writeFileSync(archive, bytes, { flag: "wx" }); fs.writeFileSync(info, manifestBody, { flag: "wx" });
const result = await runUnpack(python, [path.join(__dirname, "release_unpack.py"), "--archive", archive,
"--manifest", info, "--repository", release.flavor === "gitcode" ? manifest.repository : release.repository,
"--version", release.version, "--output", output]);
const canonical = (value) => { try { return fs.realpathSync.native(value); } catch (e) { return value; } };
if (!result || result.ok !== true || result.version !== release.version ||
canonical(result.marketplaceRoot) !== canonical(output) ||
canonical(result.pluginRoot) !== canonical(path.join(output, "plugins", "zcode-token-usage-statusbar"))) throw new Error("发布包校验或解压结果无效");
return { directory, marketplaceRoot: output, pluginRoot: result.pluginRoot, release, manifest, cleanup };
} catch (error) { cleanup(); throw error; }
}
function gitcodeReleaseFromCatalog(catalog, repository) {
if (!githubRepository(repository) || repository !== githubRepository(repository)) throw new Error("GitCode 仓库标识无效");
const entries = catalog && catalog.plugins;
const matches = Array.isArray(entries) ? entries.filter((p) => p && p.name === "zcode-token-usage-statusbar") : [];
const entry = matches.length === 1 ? matches[0] : null;
const source = entry && entry.source;
if (!catalog || catalog.name !== "zusage-marketplace" || !entry || typeof entry.version !== "string" ||
!STABLE.test(entry.version) || !source || typeof source !== "object" || source.source !== "url" ||
source.type !== "zip" || typeof source.url !== "string" || source.headers !== undefined ||
(source.stripRoot !== undefined && source.stripRoot !== false) ||
source.path !== "plugins/zcode-token-usage-statusbar" ||
typeof source.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(source.sha256)) throw new Error("GitCode 渠道清单格式无效");
const tag = "v" + entry.version;
const archiveName = `zcode-token-usage-statusbar-v${entry.version}.zip`;
const expectedPath = `/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(archiveName)}`;
const parsed = parseAssetUrl(source.url);
if (!parsed || parsed.host !== "gitcode" || !sameRepository(parsed.repository, repository) ||
parsed.path !== expectedPath) throw new Error("GitCode 渠道清单下载源与注册仓库或版本不一致");
return {
flavor: "gitcode", version: entry.version, tag, repository,
archive: { name: archiveName, size: null, browser_download_url: `https://gitcode.com/${repository}${expectedPath}` },
manifest: { name: "release-manifest.json",
browser_download_url: `https://gitcode.com/${repository}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent("release-manifest.json")}` },
url: `https://gitcode.com/${repository}/releases/tag/${encodeURIComponent(tag)}` };
}
function validateGitcodeRelease(release) {
if (!release || release.flavor !== "gitcode" || !githubRepository(release.repository) ||
release.repository !== githubRepository(release.repository) || typeof release.version !== "string" ||
!STABLE.test(release.version) || release.tag !== "v" + release.version || !release.archive ||
release.archive.name !== `zcode-token-usage-statusbar-v${release.version}.zip` ||
release.archive.size !== null) return null;
const archive = parseAssetUrl(release.archive.browser_download_url);
const manifest = parseAssetUrl(release.manifest && release.manifest.browser_download_url);
const downloadPath = `/releases/download/${encodeURIComponent(release.tag)}/`;
let page = null;
try { page = new URL(release.url); } catch (e) { }
if (!archive || archive.host !== "gitcode" || !sameRepository(archive.repository, release.repository) ||
archive.path !== downloadPath + encodeURIComponent(release.archive.name) ||
!manifest || manifest.host !== "gitcode" || !sameRepository(manifest.repository, release.repository) ||
manifest.path !== downloadPath + encodeURIComponent("release-manifest.json") ||
!page || page.protocol !== "https:" || page.hostname !== "gitcode.com" ||
page.pathname !== `/${release.repository}/releases/tag/${encodeURIComponent(release.tag)}`) return null;
return { ...release, version: release.version };
}
readChannel 据此把源码清单的仓库来源判为 market 渠道,交给宿主插件管理器直接更新。 */
function catalogChannel(catalog, pluginName = "zcode-token-usage-statusbar") {
const entries = catalog && catalog.plugins;
const matches = Array.isArray(entries) ? entries.filter((p) => p && p.name === pluginName) : [];
const entry = matches.length === 1 ? matches[0] : null;
if (!entry) return "unknown";
const source = entry.source;
if (typeof source === "string") return "source";
if (!source || typeof source !== "object") return "unknown";
return source.source === "url" && source.type === "zip" ? "release" : "source";
}
* Release 正文只存在于 GitHub/GitCode 的 Release 里(发版时 --notes-file 写入),
* 检查链路不保留 body;这里按需单拉:URL 一律由本模块按已验证来源构造,渲染进程
* 只允许传 flavor/repository/version。主机白名单独立于资产下载白名单(NOTES_API_HOSTS),
* 匿名 GET,不携带任何凭据。 */
const NOTES_MAX_JSON = 256 * 1024;
const NOTES_LIST_MAX_JSON = 1024 * 1024;
const NOTES_LIST_MAX = 20;
const NOTES_GITCODE_MIRROR = "tanranran/zcode-token-usage-statusbar";
const NOTES_API_HOSTS = {
github: { repo: githubRepository, api: "api.github.com", base: "https://api.github.com", origin: "https://github.com" },
gitcode: { repo: gitcodeRepository, api: "api.gitcode.com", base: "https://api.gitcode.com/api/v5", origin: "https://gitcode.com" },
};
function notesTag(version) {
return typeof version === "string" && STABLE.test(version) ? "v" + version : null;
}
* 返回 404(2026-11-04 匿名实测;v1.5.9–v1.5.10 缺前缀,GitCode 渠道更新日志被整体误判
* 为 missing)。published_at 恒空,用 created_at;列表默认升序,需显式 direction=desc
* (代码内仍按版本号重排,不依赖该参数)。 */
function notesHost(flavor, repository) {
const host = NOTES_API_HOSTS[flavor];
if (!host) return null;
const repo = host.repo(repository);
return repo && repo === repository ? { host, repo } : null;
}
function notesRow(row, flavor, repo, tag) {
if (!row || typeof row !== "object" || typeof row.tag_name !== "string" || row.tag_name !== tag ||
typeof row.body !== "string" || !row.body.trim()) return null;
const host = NOTES_API_HOSTS[flavor];
const name = typeof row.name === "string" && row.name.trim() ? row.name.trim().slice(0, 200) : tag;
const publishedAt = typeof row.published_at === "string" && row.published_at ? row.published_at
: typeof row.created_at === "string" ? row.created_at : "";
return { flavor, repository: repo, version: tag.slice(1), tag, name,
body: row.body, publishedAt, prerelease: row.prerelease === true,
url: `${host.origin}/${repo}/releases/tag/${encodeURIComponent(tag)}` };
}
function classifyNotesStatus(code) {
return code === 404 || code === 422 ? "missing" : code === 403 || code === 429 ? "rate-limited" : "error";
}
function notesFetch(url, limit) {
return fetchBytes(url, limit, undefined, Object.values(NOTES_API_HOSTS).map((h) => h.api));
}
async function fetchReleaseNotes(options) {
const { flavor, repository, version, fetch = notesFetch, limit = NOTES_MAX_JSON } = options || {};
const found = notesHost(flavor, repository);
const tag = notesTag(version);
if (!found || !tag) return { ok: false, reason: "unsupported" };
const { host, repo } = found;
let body;
try { body = await fetch(`${host.base}/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`, limit); }
catch (error) {
if (error && typeof error.status === "number") return { ok: false, reason: classifyNotesStatus(error.status) };
return { ok: false, reason: "network", message: String((error && error.message) || error).slice(0, 300) };
}
let row;
try { row = JSON.parse(body.toString("utf8")); }
catch (e) { return { ok: false, reason: "error", message: "更新日志响应不是有效 JSON" }; }
const notes = notesRow(row, flavor, repo, tag);
return notes ? { ok: true, notes } : { ok: false, reason: "missing" };
}
async function fetchReleaseNotesList(options) {
const { flavor, repository, fetch = notesFetch } = options || {};
const found = notesHost(flavor, repository);
if (!found) return { ok: false, reason: "unsupported" };
const { host, repo } = found;
let body;
try { body = await fetch(`${host.base}/repos/${repo}/releases?per_page=100&page=1&direction=desc`, NOTES_LIST_MAX_JSON); }
catch (error) {
if (error && typeof error.status === "number") return { ok: false, reason: classifyNotesStatus(error.status) };
return { ok: false, reason: "network", message: String((error && error.message) || error).slice(0, 300) };
}
let rows;
try { rows = JSON.parse(body.toString("utf8")); }
catch (e) { return { ok: false, reason: "error", message: "更新日志列表响应不是有效 JSON" }; }
if (!Array.isArray(rows)) return { ok: false, reason: "error", message: "更新日志列表格式无效" };
const seen = new Set(), picked = [];
for (const row of rows) {
if (!row || typeof row.tag_name !== "string" || row.prerelease === true) continue;
const tag = notesTag(row.tag_name.startsWith("v") ? row.tag_name.slice(1) : "");
if (!tag || seen.has(tag)) continue;
seen.add(tag);
picked.push({ flavor, repository: repo, version: tag.slice(1), tag,
name: typeof row.name === "string" && row.name.trim() ? row.name.trim().slice(0, 200) : tag,
publishedAt: typeof row.published_at === "string" && row.published_at ? row.published_at
: typeof row.created_at === "string" ? row.created_at : "",
prerelease: row.prerelease === true,
url: `${host.origin}/${repo}/releases/tag/${encodeURIComponent(tag)}` });
}
picked.sort((a, b) => {
const av = a.version.split(".").map(Number), bv = b.version.split(".").map(Number);
for (let i = 0; i < 3; i++) { if (av[i] !== bv[i]) return bv[i] - av[i]; }
return 0;
});
return { ok: true, releases: picked.slice(0, NOTES_LIST_MAX) };
}
function createNotesCache(options) {
const { file, now = Date.now, ttlMs = 60 * 60 * 1000, maxEntries = 24, maxBodyChars = 64 * 1024 } = options || {};
let entries = null;
const validNotes = (notes) => {
if (!notes || typeof notes !== "object") return false;
if (notes.body !== undefined && (typeof notes.body !== "string" || notes.body.length > maxBodyChars)) return false;
if (notes.releases !== undefined && !Array.isArray(notes.releases)) return false;
return true;
};
const load = () => {
if (entries) return entries;
entries = {};
try {
const data = JSON.parse(fs.readFileSync(file, "utf8"));
const rows = data && typeof data === "object" && !Array.isArray(data) ? data.entries : null;
if (rows && typeof rows === "object" && !Array.isArray(rows)) {
for (const key of Object.keys(rows)) {
const value = rows[key];
if (typeof key === "string" && key.length <= 400 && value && typeof value === "object" &&
typeof value.fetchedAt === "number" && Number.isSafeInteger(value.fetchedAt) &&
value.fetchedAt >= 0 && value.fetchedAt <= 8.64e15 && validNotes(value.notes)) entries[key] = value;
}
}
} catch (e) { if (e.code !== "ENOENT") entries = {}; }
return entries;
};
const persist = () => {
const tmp = `${file}.${process.pid}.tmp`;
try {
fs.writeFileSync(tmp, JSON.stringify({ version: 1, entries }, null, 2));
fs.renameSync(tmp, file);
} catch (e) { try { fs.unlinkSync(tmp); } catch (_) { } }
};
return {
key: (flavor, repository, slot) => `${flavor}|${repository}|${slot || ""}`.slice(0, 400),
get(key) {
const row = load()[key];
return row && row.notes ? row : null;
},
fresh(key) {
const row = this.get(key);
return !!row && now() - row.fetchedAt >= 0 && now() - row.fetchedAt < ttlMs;
},
put(key, notes) {
const rows = load();
const trimmed = notes && typeof notes.body === "string" && notes.body.length > maxBodyChars
? { ...notes, body: notes.body.slice(0, maxBodyChars), truncated: true } : notes;
rows[key] = { notes: trimmed, fetchedAt: now() };
const keys = Object.keys(rows);
if (keys.length > maxEntries) {
keys.sort((a, b) => (rows[a].fetchedAt || 0) - (rows[b].fetchedAt || 0));
for (const k of keys.slice(0, keys.length - maxEntries)) delete rows[k];
}
persist();
return trimmed;
},
};
}
function createNotesService(options) {
const { fetch = notesFetch, cache } = options || {};
const inflight = new Map();
* 失败 = { ok:false, reason, message? }。缓存只存载荷本体(ok 包装不落盘),键带
* n:/list 命名空间——version 不经 STABLE 校验就进键名,也不能与列表键碰撞。 */
async function serve(key, refresh) {
if (cache && !refresh.fresh && cache.fresh(key)) {
const row = cache.get(key);
if (row) return { ...row.notes, ok: true, fromCache: true, cachedAt: row.fetchedAt };
}
const pending = inflight.get(key);
if (pending) return pending;
const job = (async () => {
const result = await refresh.run();
const payload = result.ok ? (result.notes || { releases: result.releases }) : null;
if (result.ok && result.mirror && payload) payload.mirror = true;
if (result.ok && cache) return { ...cache.put(key, payload), ok: true };
if (result.ok) return { ...payload, ok: true };
if (cache && !refresh.fresh) {
const row = cache.get(key);
if (row) return { ...row.notes, ok: true, fromCache: true, cachedAt: row.fetchedAt, stale: true };
}
return result;
})();
inflight.set(key, job);
try { return await job; } finally { inflight.delete(key); }
}
* 清单回退同哲学:失败时退官方 GitCode 镜像取同号内容,结果带 mirror:true 供
* 浮层标注来源。仅 github 形态回退(gitcode 渠道本身不受此限)。 */
const notesFallback = (primary, runner) => async () => {
const result = await runner();
if (result.ok || (result.reason !== "rate-limited" && result.reason !== "network")) return result;
if (primary.flavor === "gitcode") return result;
const mirror = await runner({ flavor: "gitcode", repository: NOTES_GITCODE_MIRROR });
if (mirror.ok) return { ...mirror, mirror: true };
if (mirror.reason === "missing") return { ...mirror, mirror: true };
return result;
};
return {
getNotes(params) {
const { flavor, repository, version, force } = params || {};
const key = cache ? cache.key(flavor, repository, "n:" + (version || "")) : "n:" + flavor + "|" + repository + "|" + version;
return serve(key, { fresh: force === true,
run: notesFallback({ flavor, repository }, (override) => fetchReleaseNotes(override
? { ...override, version, fetch }
: { flavor, repository, version, fetch })) });
},
getList(params) {
const { flavor, repository, force } = params || {};
const key = cache ? cache.key(flavor, repository, "list") : "list:" + flavor + "|" + repository;
return serve(key, { fresh: force === true,
run: notesFallback({ flavor, repository }, (override) => fetchReleaseNotesList(override
? { ...override, fetch }
: { flavor, repository, fetch })) });
},
};
}
module.exports = { sameRepository, sameAssetUrl, githubRepository, gitcodeRepository, readChannel, checkRelease, gitcodeReleaseFromCatalog, githubReleaseFromCatalog, validateGitcodeRelease, validateGithubCatalogRelease,
downloadRelease, validateRelease, validateManifest, catalogChannel,
fetchBytes, fetchReleaseNotes, fetchReleaseNotesList, createNotesCache, createNotesService, notesTag, releaseAcceptHeader, NOTES_API_HOSTS, NOTES_MAX_JSON, NOTES_LIST_MAX_JSON, NOTES_LIST_MAX, MAX_ASSET, MAX_JSON, PLUGIN_ID };