Batch of red-team r3 fixes that were in the working tree before the 2026-09-28 design overhaul, committed as one unit with their tests. - desktop main: STT timeouts and sidecar, voice recording store, sync (credentials, audio, knowledge reindex, push gates), runtime provisioner, update policy, AltGr keybindings, voice-command policy, dictionary file codec/limits, meeting transcript condensing and a local recording ledger so interrupted-session recovery only closes meetings this device recorded (a phone's live meeting is left alone). - mobile: login CSRF via implicit token callbacks rejected, account deletion/retention, durable queue retention, knowledge realtime without unfiltered DELETE, meeting re-record failure paths, cloud STT client, preferences store/resync. - core: text chunking splits long unbroken transcripts to fit, template field policy, dictionary limits, meeting markdown inline handling. - server: payple webhook policy and cancellation order scope, meeting document generation quota, team RPC null-role guard, unified LLM quota in-flight accounting, knowledge chunk vector index, meeting re-record failure paths (migrations 20260929*). - ci: portable/runtime feed gates, update-policy schema, Forgejo file delete and alias planning. Four older tests are updated to the new contracts rather than the old behavior: token-pair auth callbacks are rejected, knowledge realtime no longer subscribes to DELETE, long transcript lines are split, and meeting recovery requires the local recording ledger for empty rows.
432 lines
18 KiB
JavaScript
432 lines
18 KiB
JavaScript
// scripts/ci/publish-forgejo-release.mjs
|
|
// Canonical release publisher for D3RO Voice.
|
|
//
|
|
// Tag 파이프라인의 release 스테이지에서 실행:
|
|
// 1) apps/desktop/release/<version>/ 자산을 Forgejo Generic Package Registry에 업로드
|
|
// - 버전별 경로: /api/packages/<owner>/generic/d3ro-voice/<version>/<file>
|
|
// - latest 경로: /api/packages/<owner>/generic/d3ro-voice/latest/<file> (electron-updater feed)
|
|
// 2) release/update-policy.json을 latest feed에 게시 (원격 정책/킬 스위치)
|
|
// 3) Forgejo Release 생성/갱신 + 설치 자산 첨부 (admin·site 다운로드 허브)
|
|
//
|
|
// 배포 순서 보장: 설치파일/blockmap을 먼저 올리고 update metadata(latest.yml)를
|
|
// 마지막에 게시한다. 기존 설치본이 배포 도중 404를 받지 않는다.
|
|
//
|
|
// 필요 env:
|
|
// FORGEJO_TOKEN (write:package + write:repository) 또는 FORGEJO_USERNAME/FORGEJO_PASSWORD
|
|
// 선택: FORGEJO_REPO=git.chanpaca.net/yunchan/d3ro-voice
|
|
// 선택: FORGEJO_RELEASE_TAG (기본: CI_COMMIT_TAG/GITHUB_REF_NAME/FORGEJO_REF_NAME)
|
|
// 선택: FORGEJO_PUBLISH_DRY_RUN=1 (업로드 없이 사전점검)
|
|
|
|
import { createHash } from "node:crypto";
|
|
import { createReadStream, readFileSync } from "node:fs";
|
|
import { readFile, readdir, stat } from "node:fs/promises";
|
|
import { basename, join } from "node:path";
|
|
import process from "node:process";
|
|
import { fileURLToPath, URL } from "node:url";
|
|
import credentialHelpers from "../lib/credentials.cjs";
|
|
import {
|
|
assertNoImmutableConflicts,
|
|
classifyImmutableAssets,
|
|
readRemotePackageDigests,
|
|
uploadImmutableAsset,
|
|
} from "./lib/immutable-package-guard.mjs";
|
|
import { publishLatestAlias, readPublishedFeedVersion } from "./lib/latest-feed-guard.mjs";
|
|
import {
|
|
RUNTIME_MIN_VERSION_SOURCE,
|
|
WINDOWS_X64_TARGET,
|
|
assertRuntimeFeedCompatible,
|
|
parseRuntimeMinVersions,
|
|
} from "./lib/runtime-feed-gate.mjs";
|
|
import { assertValidUpdatePolicy } from "./lib/update-policy-schema.mjs";
|
|
|
|
const { forgejoAuthorization } = credentialHelpers;
|
|
|
|
const DEFAULT_REPO = "git.chanpaca.net/yunchan/d3ro-voice";
|
|
const PACKAGE_NAME = "d3ro-voice";
|
|
const POLICY_FILENAME = "update-policy.json";
|
|
|
|
const dryRun = process.env.FORGEJO_PUBLISH_DRY_RUN === "1" || process.argv.includes("--check");
|
|
|
|
const tag = resolveTag();
|
|
if (!tag) throw new Error("Release tag is required (CI_COMMIT_TAG / GITHUB_REF_NAME / FORGEJO_REF_NAME).");
|
|
const version = tag.replace(/^v/, "");
|
|
if (!/^\d+\.\d+\.\d+$/.test(version)) {
|
|
throw new Error(`Only stable semver release tags are supported by the canonical publisher: ${tag}`);
|
|
}
|
|
|
|
const { origin, owner, repo } = parseRepo(resolveRepoUrl());
|
|
const packageVersionedUrl = `${origin}/api/packages/${owner}/generic/${PACKAGE_NAME}/${version}`;
|
|
const packageLatestUrl = `${origin}/api/packages/${owner}/generic/${PACKAGE_NAME}/latest`;
|
|
// 패키지 파일 목록 API는 파일별 sha256을 준다 (generic registry는 HEAD 405, 해시 헤더 없음).
|
|
const packageFilesApiBase = `${origin}/api/v1/packages/${owner}/generic/${PACKAGE_NAME}`;
|
|
const packageVersionedFilesApiUrl = `${packageFilesApiBase}/${encodeURIComponent(version)}/files`;
|
|
const packageLatestFilesApiUrl = `${packageFilesApiBase}/latest/files`;
|
|
const releasesApiUrl = `${origin}/api/v1/repos/${owner}/${repo}/releases`;
|
|
|
|
const authorization = dryRun && !process.env.FORGEJO_TOKEN && !process.env.FORGEJO_USERNAME
|
|
? null
|
|
: forgejoAuthorization();
|
|
|
|
const productVersion = JSON.parse(
|
|
await readFile(fileURLToPath(new URL("../../release/product-version.json", import.meta.url)), "utf8"),
|
|
);
|
|
if (tag !== `v${productVersion.version}`) {
|
|
throw new Error(`Release tag ${tag} does not match product version v${productVersion.version}.`);
|
|
}
|
|
|
|
const policyPath = fileURLToPath(new URL("../../release/update-policy.json", import.meta.url));
|
|
// schemaVersion 만 보면 "killSwitch": "true" 같은 오타가 클라이언트에서 가장 허용적인 값으로 바뀐다.
|
|
assertValidUpdatePolicy(readFileSync(policyPath), "release/update-policy.json");
|
|
|
|
const releaseDirectory = process.env.FORGEJO_RELEASE_DIR?.trim()
|
|
? process.env.FORGEJO_RELEASE_DIR.trim()
|
|
: fileURLToPath(new URL(`../../apps/desktop/release/${version}/`, import.meta.url));
|
|
const ASSET_PATTERN = /(\.exe|\.dmg|\.zip|\.blockmap|^(latest|beta|alpha)(-mac|-linux)?\.yml)$/;
|
|
|
|
const files = [];
|
|
for (const name of await readdir(releaseDirectory)) {
|
|
if (!ASSET_PATTERN.test(name)) continue;
|
|
const path = join(releaseDirectory, name);
|
|
if ((await stat(path)).isFile()) files.push({ name, path });
|
|
}
|
|
if (files.length === 0) throw new Error(`No release assets found in ${releaseDirectory}`);
|
|
if (!files.some((file) => file.name.endsWith(".exe"))) {
|
|
throw new Error(`Windows installer is missing for ${tag}.`);
|
|
}
|
|
if (!files.some((file) => file.name === "latest.yml")) {
|
|
throw new Error(`latest.yml is missing for ${tag} — check electron-builder.yml publish config.`);
|
|
}
|
|
if (!files.some((file) => file.name.endsWith(".dmg"))) {
|
|
process.stdout.write(`WARNING: macOS artifacts missing for ${tag} — Windows-only release.\n`);
|
|
}
|
|
|
|
const sorted = files.sort((a, b) => a.name.localeCompare(b.name));
|
|
const sha256ByFile = new Map();
|
|
for (const file of sorted) sha256ByFile.set(file.name, await sha256(file.path));
|
|
|
|
if (dryRun) {
|
|
process.stdout.write(
|
|
`[forgejo] dry-run OK — ${tag}, ${sorted.length} assets, feed ${packageLatestUrl}\n`,
|
|
);
|
|
process.exit(0);
|
|
}
|
|
|
|
// 런타임 게이트 — 이 빌드의 RUNTIME_MIN_VERSION 을 runtime-latest 가 만족하지 못하면 어떤 파일도
|
|
// 올리기 전에 멈춘다. release.yml 과 portable.yml 은 같은 태그에서 서로 기다리지 않고 돌기 때문에,
|
|
// 최소 버전을 올린 릴리스는 런타임 게시가 끝난 뒤 이 job 을 다시 실행해야 한다.
|
|
const runtimeFeedUrl = `${origin}/api/packages/${owner}/generic/${PACKAGE_NAME}/runtime-latest/runtime.json`;
|
|
const runtimeFeed = await assertRuntimeFeedCompatible({
|
|
url: runtimeFeedUrl,
|
|
fetchImpl: forgejoFetch,
|
|
minVersions: parseRuntimeMinVersions(
|
|
readFileSync(fileURLToPath(new URL(`../../${RUNTIME_MIN_VERSION_SOURCE}`, import.meta.url)), "utf8"),
|
|
),
|
|
target: WINDOWS_X64_TARGET,
|
|
});
|
|
process.stdout.write(` runtime-latest ${runtimeFeed.version} satisfies this build's runtime minimum\n`);
|
|
|
|
// 0) 이미 게시된 버전은 재게시하지 않는다 (SemVer 동일 버전 재릴리스 금지).
|
|
// 버전별 경로의 원격 sha256이 로컬과 같으면 재시도/재실행으로 보고 건너뛰고,
|
|
// 다른 바이트(재빌드·재서명된 설치본 등)가 있으면 어떤 파일도 건드리기 전에 fail-closed로 중단한다.
|
|
const readVersionedDigests = () =>
|
|
readRemotePackageDigests({ filesApiUrl: packageVersionedFilesApiUrl, fetchImpl: forgejoFetch });
|
|
const versionedAssets = sorted.map((file) => ({
|
|
file,
|
|
name: safeAssetName(file.name),
|
|
sha256: sha256ByFile.get(file.name),
|
|
}));
|
|
const versionedPlan = classifyImmutableAssets({
|
|
localFiles: versionedAssets,
|
|
remoteDigests: await readVersionedDigests(),
|
|
});
|
|
assertNoImmutableConflicts({ tag, conflicting: versionedPlan.conflicting });
|
|
for (const asset of versionedPlan.identical) {
|
|
process.stdout.write(` verified existing immutable ${asset.name} (sha256 ${asset.sha256})\n`);
|
|
}
|
|
|
|
// 1) 버전별(immutable) 패키지 업로드 — 409여도 DELETE/덮어쓰기는 하지 않는다.
|
|
for (const asset of versionedPlan.upload) {
|
|
const outcome = await uploadImmutableAsset({
|
|
url: `${packageVersionedUrl}/${encodeURIComponent(asset.name)}`,
|
|
name: asset.name,
|
|
sha256: asset.sha256,
|
|
body: await readFile(asset.file.path),
|
|
fetchImpl: forgejoFetch,
|
|
readRemoteDigest: async () => (await readVersionedDigests()).get(asset.name),
|
|
});
|
|
process.stdout.write(
|
|
outcome === "uploaded"
|
|
? ` uploaded ${asset.name}\n`
|
|
: ` verified existing immutable ${asset.name} (sha256 ${asset.sha256})\n`,
|
|
);
|
|
}
|
|
|
|
// 2) latest feed 갱신 — 설치 자산 먼저, metadata 마지막, 정책 파일 맨 마지막.
|
|
// 이미 더 새 버전이 latest에 게시돼 있으면(이전 태그 재실행·mirror 지연 완료)
|
|
// alias와 update-policy.json은 건드리지 않는다. 버전별 경로와 Release는 계속 게시한다.
|
|
const latestOrder = [...sorted].sort((a, b) => {
|
|
const order = Number(isUpdateMetadata(a.name)) - Number(isUpdateMetadata(b.name));
|
|
return order || a.name.localeCompare(b.name);
|
|
});
|
|
const latestFeed = await publishLatestAlias({
|
|
publishingVersion: version,
|
|
files: [...latestOrder, { name: POLICY_FILENAME, path: policyPath }],
|
|
readPublishedVersion: () =>
|
|
readPublishedFeedVersion({ url: `${packageLatestUrl}/latest.yml`, fetchImpl: forgejoFetch }),
|
|
upload: (file) =>
|
|
uploadToRegistry(file, `${packageLatestUrl}/${encodeURIComponent(safeAssetName(file.name))}`),
|
|
});
|
|
if (!latestFeed.update) {
|
|
process.stdout.write(
|
|
`WARNING: latest feed already serves ${latestFeed.publishedVersion} (newer than ${version}) — ` +
|
|
"kept latest.yml and update-policy.json unchanged.\n",
|
|
);
|
|
}
|
|
|
|
// 3) metadata 참조 검증 + 공개 URL 재검증
|
|
for (const file of latestOrder.filter((candidate) => isYamlUpdateMetadata(candidate.name))) {
|
|
validateUpdateMetadataReferences(file, latestOrder);
|
|
}
|
|
if (latestFeed.update) {
|
|
for (const name of ["latest.yml", POLICY_FILENAME]) {
|
|
await verifyPublicFile(`${packageLatestUrl}/${name}`);
|
|
}
|
|
} else {
|
|
await verifyPublicFile(`${packageVersionedUrl}/latest.yml`);
|
|
}
|
|
|
|
// 4) Forgejo Release 생성/갱신 + 자산 첨부
|
|
const description = await buildReleaseDescription();
|
|
const releaseId = await upsertRelease(description);
|
|
for (const file of sorted) {
|
|
await uploadReleaseAsset(releaseId, file);
|
|
}
|
|
|
|
process.stdout.write(`Published ${tag} to Forgejo (${sorted.length} assets, release ${releasesApiUrl}/${tag}).\n`);
|
|
|
|
// ── helpers ─────────────────────────────────────────────────
|
|
|
|
function resolveTag() {
|
|
return (
|
|
process.env.CI_COMMIT_TAG?.trim() ||
|
|
process.env.FORGEJO_RELEASE_TAG?.trim() ||
|
|
process.env.FORGEJO_REF_NAME?.trim() ||
|
|
process.env.GITHUB_REF_NAME?.trim() ||
|
|
process.argv.find((arg) => /^v\d+\.\d+\.\d+$/.test(arg)) ||
|
|
""
|
|
);
|
|
}
|
|
|
|
function resolveRepoUrl() {
|
|
const configured = process.env.FORGEJO_REPO?.trim();
|
|
const value = configured || DEFAULT_REPO;
|
|
return value.startsWith("http") ? value : `https://${value}`;
|
|
}
|
|
|
|
function parseRepo(rawUrl) {
|
|
const parsed = new URL(rawUrl);
|
|
if (parsed.protocol !== "https:") throw new Error("Forgejo repo URL must use HTTPS");
|
|
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
if (segments.length !== 2) throw new Error("Forgejo repo URL must point to owner/repo");
|
|
return { origin: parsed.origin, owner: segments[0], repo: segments[1] };
|
|
}
|
|
|
|
function safeAssetName(name) {
|
|
return basename(name).replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
}
|
|
|
|
function isUpdateMetadata(name) {
|
|
return /(?:\.blockmap$|^(?:latest|beta|alpha)(?:-mac|-linux)?\.yml$|^update-policy\.json$)/.test(name);
|
|
}
|
|
|
|
function isYamlUpdateMetadata(name) {
|
|
return /^(?:latest|beta|alpha)(?:-mac|-linux)?\.yml$/.test(name);
|
|
}
|
|
|
|
async function sha256(path) {
|
|
const hash = createHash("sha256");
|
|
await new Promise((resolve, reject) => {
|
|
createReadStream(path)
|
|
.on("data", (chunk) => hash.update(chunk))
|
|
.on("end", resolve)
|
|
.on("error", reject);
|
|
});
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
let latestDigestsPromise = null;
|
|
|
|
/** latest alias에 이미 같은 바이트가 있는지 본다. 목록을 못 읽으면 "모름"으로 보고 교체한다. */
|
|
async function latestAlreadyHolds(name, digest) {
|
|
latestDigestsPromise ??= readRemotePackageDigests({
|
|
filesApiUrl: packageLatestFilesApiUrl,
|
|
fetchImpl: forgejoFetch,
|
|
}).catch(() => new Map());
|
|
const remote = (await latestDigestsPromise).get(name);
|
|
return Boolean(remote) && remote === digest;
|
|
}
|
|
|
|
/**
|
|
* latest(mutable) alias 파일 교체. 같은 바이트가 이미 있으면 건드리지 않아
|
|
* 동일 버전 재실행 때 DELETE→PUT 사이 404 구간을 만들지 않는다.
|
|
*/
|
|
async function uploadToRegistry(file, url) {
|
|
const name = safeAssetName(file.name);
|
|
const body = await readFile(file.path);
|
|
const digest = createHash("sha256").update(body).digest("hex");
|
|
if (await latestAlreadyHolds(name, digest)) {
|
|
process.stdout.write(` latest already holds identical ${file.name}\n`);
|
|
return;
|
|
}
|
|
|
|
// 기존 파일이 있으면 먼저 삭제하여 409 Conflict 후 이중 전송으로 인한
|
|
// Cloudflare 타임아웃(HTTP 524)을 원천 차단한다.
|
|
await forgejoFetch(url, { method: "DELETE" }).catch(() => null);
|
|
|
|
const response = await forgejoFetch(url, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/octet-stream" },
|
|
body,
|
|
});
|
|
|
|
if (response.ok) {
|
|
process.stdout.write(` uploaded ${file.name}\n`);
|
|
return;
|
|
}
|
|
if (response.status === 409) {
|
|
// alias는 가변 경로다: 기존 파일을 지우고 다시 올린다. (버전별 경로에는 절대 적용하지 않는다.)
|
|
await forgejoFetch(url, { method: "DELETE" });
|
|
const retry = await forgejoFetch(url, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/octet-stream" },
|
|
body,
|
|
});
|
|
if (!retry.ok) {
|
|
throw new Error(`registry upload failed for ${file.name}: HTTP ${retry.status} ${await retry.text()}`);
|
|
}
|
|
process.stdout.write(` replaced ${file.name}\n`);
|
|
return;
|
|
}
|
|
throw new Error(`registry upload failed for ${file.name}: HTTP ${response.status} ${await response.text()}`);
|
|
}
|
|
|
|
function validateUpdateMetadataReferences(metadataFile, uploadedFiles) {
|
|
const text = readFileSync(metadataFile.path, "utf8");
|
|
const uploadedNames = new Set(uploadedFiles.map((file) => safeAssetName(file.name)));
|
|
const references = [...text.matchAll(/^\s*(?:-\s+url:|path:)\s*["']?([^"'\r\n]+?)["']?\s*$/gm)].map(
|
|
(match) => basename(match[1].trim()),
|
|
);
|
|
if (references.length === 0) {
|
|
throw new Error(`${metadataFile.name} does not reference a release artifact.`);
|
|
}
|
|
for (const reference of references) {
|
|
if (!uploadedNames.has(reference)) {
|
|
throw new Error(`${metadataFile.name} references missing release artifact ${reference}.`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function verifyPublicFile(url) {
|
|
const response = await fetch(`${url}?release=${encodeURIComponent(tag)}`, { cache: "no-store" });
|
|
if (!response.ok) throw new Error(`Public updater verification failed for ${url}: HTTP ${response.status}`);
|
|
const body = await response.text();
|
|
if (!body.trim()) throw new Error(`Public updater file is empty: ${url}`);
|
|
}
|
|
|
|
async function buildReleaseDescription() {
|
|
const changelog = await readFile(
|
|
fileURLToPath(new URL("../../CHANGELOG.md", import.meta.url)),
|
|
"utf8",
|
|
);
|
|
const section = extractChangelogSection(changelog, version);
|
|
if (!section) throw new Error(`CHANGELOG.md is missing a ${version} release section.`);
|
|
const checksums = [...sha256ByFile.entries()]
|
|
.map(([name, hash]) => `- \`${name}\`: \`${hash}\``)
|
|
.join("\n");
|
|
return `${section}\n\n### SHA-256\n${checksums}`;
|
|
}
|
|
|
|
function extractChangelogSection(changelog, targetVersion) {
|
|
const lines = changelog.split("\n");
|
|
const escaped = targetVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const headerPattern = new RegExp(`^##\\s+\\[?v?${escaped}(?:\\]|\\s|$)`);
|
|
const start = lines.findIndex((line) => headerPattern.test(line));
|
|
if (start === -1) return null;
|
|
let end = lines.length;
|
|
for (let index = start + 1; index < lines.length; index++) {
|
|
if (/^##\s+/.test(lines[index])) {
|
|
end = index;
|
|
break;
|
|
}
|
|
}
|
|
return lines.slice(start, end).join("\n").trim();
|
|
}
|
|
|
|
async function upsertRelease(description) {
|
|
const byTag = await forgejoFetch(`${releasesApiUrl}/tags/${encodeURIComponent(tag)}`);
|
|
if (byTag.ok) {
|
|
const existing = await byTag.json();
|
|
const update = await forgejoFetch(`${releasesApiUrl}/${existing.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name: `D3RO Voice ${tag}`, body: description, prerelease: false, draft: false }),
|
|
});
|
|
if (!update.ok) throw new Error(`release update failed: HTTP ${update.status} ${await update.text()}`);
|
|
return existing.id;
|
|
}
|
|
if (byTag.status !== 404) {
|
|
throw new Error(`release lookup failed: HTTP ${byTag.status} ${await byTag.text()}`);
|
|
}
|
|
|
|
const create = await forgejoFetch(releasesApiUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
tag_name: tag,
|
|
target_commitish: "main",
|
|
name: `D3RO Voice ${tag}`,
|
|
body: description,
|
|
draft: false,
|
|
prerelease: false,
|
|
}),
|
|
});
|
|
if (!create.ok) throw new Error(`release creation failed: HTTP ${create.status} ${await create.text()}`);
|
|
const created = await create.json();
|
|
return created.id;
|
|
}
|
|
|
|
async function uploadReleaseAsset(releaseId, file) {
|
|
const assetName = safeAssetName(file.name);
|
|
// 재실행 대비: 같은 이름의 기존 asset 제거
|
|
const list = await forgejoFetch(`${releasesApiUrl}/${releaseId}/assets`);
|
|
if (list.ok) {
|
|
const assets = await list.json();
|
|
for (const asset of Array.isArray(assets) ? assets : []) {
|
|
if (asset?.name === assetName && typeof asset.id === "number") {
|
|
await forgejoFetch(`${releasesApiUrl}/${releaseId}/assets/${asset.id}`, { method: "DELETE" });
|
|
}
|
|
}
|
|
}
|
|
|
|
const buffer = await readFile(file.path);
|
|
const form = new FormData();
|
|
form.append("attachment", new Blob([buffer]), assetName);
|
|
const response = await forgejoFetch(
|
|
`${releasesApiUrl}/${releaseId}/assets?name=${encodeURIComponent(assetName)}`,
|
|
{ method: "POST", body: form },
|
|
);
|
|
if (!response.ok) {
|
|
throw new Error(`release asset upload failed for ${assetName}: HTTP ${response.status} ${await response.text()}`);
|
|
}
|
|
process.stdout.write(` attached ${assetName}\n`);
|
|
}
|
|
|
|
function forgejoFetch(url, init = {}) {
|
|
return fetch(url, {
|
|
...init,
|
|
headers: {
|
|
...(authorization ? { Authorization: authorization } : {}),
|
|
...(init.headers ?? {}),
|
|
},
|
|
});
|
|
}
|