SnowCat 雪猫底层全源码:NapCat 4.18.28 底座 + SnowLuma 能力移植 + V8 通道适配 9.9.37

26 包 monorepo(TS 全源码):
- snowcat-core: NT 内核 API 封装 + listener(含 9.9.37 六回调适配)+ Packet 发包
- snowcat-onebot: OneBot v11 全动作 + MCP Streamable HTTP 适配器 + 稳定线
- snowcat-webui-frontend/backend: 雪猫控制台(React+HeroUI)
- snowcat-shell: 单文件 bundle 出口(vite inlineDynamicImports)
- snowcat-native: napi2native / MoeHoo / ffmpeg / pty 运行时
- 56 项 SnowLuma 独门动作移植(qzone 全家桶/群相册/装扮/闪照/收藏)
- 工具件: 解包器/启动器/便携制作器/doctor

GitHub 关联已全量指向 DandanLLab/SnowCatQQ
This commit is contained in:
天天的鸟蛋蛋 committed 2026-11-12 06:27:36 +08:00
commit b9655a5ab1
1048 files changed
+130851

No files matched your search

+417
View File
@@ -0,0 +1,417 @@
/**
* 构建状态评论模板
*/
export const COMMENT_MARKER = '<!-- napcat-pr-build -->';
export const DOCKER_COMMENT_MARKER = '<!-- napcat-pr-docker -->';
// 默认代理前缀(加速中国大陆访问 GitHub Release)
// 格式:https://ghfast.top/{full-github-url}
export const PROXY_BASE = 'https://gh.llkk.cc';
/** 将 GitHub 直链转换为 ghfast.top 加速链接 */
export function toProxyUrl (directUrl: string): string {
return `${PROXY_BASE}/${directUrl}`;
}
export type BuildStatus = 'success' | 'failure' | 'cancelled' | 'pending' | 'unknown';
export interface BuildTarget {
name: string;
status: BuildStatus;
error?: string;
downloadUrl?: string; // Artifact 直接下载链接
}
// ============== 辅助函数 ==============
function formatSha (sha: string): string {
return sha && sha.length >= 7 ? sha.substring(0, 7) : sha || 'unknown';
}
function escapeCodeBlock (text: string): string {
// 替换 ``` 为转义形式,避免破坏 Markdown 代码块
return text.replace(/```/g, '\\`\\`\\`');
}
function getTimeString (): string {
return new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
}
// ============== 状态图标 ==============
export function getStatusIcon (status: BuildStatus): string {
switch (status) {
case 'success':
return '✅ 成功';
case 'pending':
return '⏳ 构建中...';
case 'cancelled':
return '⚪ 已取消';
case 'failure':
return '❌ 失败';
default:
return '❓ 未知';
}
}
function getStatusEmoji (status: BuildStatus): string {
switch (status) {
case 'success': return '✅';
case 'pending': return '⏳';
case 'cancelled': return '⚪';
case 'failure': return '❌';
default: return '❓';
}
}
// ============== 构建中评论 ==============
export function generateBuildingComment (prSha: string, targets: string[]): string {
const time = getTimeString();
const shortSha = formatSha(prSha);
const lines: string[] = [
COMMENT_MARKER,
'',
'<div align="center">',
'',
'# 🔨 NapCat 构建中',
'',
'![Building](https://img.shields.io/badge/状态-构建中-yellow?style=for-the-badge&logo=github-actions&logoColor=white)',
'',
'</div>',
'',
'---',
'',
'## 📦 构建目标',
'',
'| 包名 | 状态 | 说明 |',
'| :--- | :---: | :--- |',
...targets.map(name => `| \`${name}\` | ⏳ | 正在构建... |`),
'',
'---',
'',
'## 📋 构建信息',
'',
'| 项目 | 值 |',
'| :--- | :--- |',
`| 📝 提交 | \`${shortSha}\` |`,
`| 🕐 开始时间 | ${time} |`,
'',
'---',
'',
'<div align="center">',
'',
'> ⏳ **构建进行中,请稍候...**',
'>',
'> 构建完成后将自动更新此评论',
'',
'</div>',
];
return lines.join('\n');
}
// ============== 构建结果评论 ==============
export interface InstallInfo {
installUrl: string;
releaseUrl: string;
}
export function generateResultComment (
targets: BuildTarget[],
prSha: string,
runId: string,
repository: string,
version?: string,
installInfo?: InstallInfo,
publishFailed?: boolean
): string {
const runUrl = `https://github.com/${repository}/actions/runs/${runId}`;
const shortSha = formatSha(prSha);
const time = getTimeString();
const allSuccess = targets.every(t => t.status === 'success');
const anyCancelled = targets.some(t => t.status === 'cancelled');
const anyFailure = targets.some(t => t.status === 'failure');
// 状态徽章
let statusBadge: string;
let headerTitle: string;
if (allSuccess) {
statusBadge = '![Success](https://img.shields.io/badge/状态-构建成功-success?style=for-the-badge&logo=github-actions&logoColor=white)';
headerTitle = '# ✅ NapCat 构建成功';
} else if (anyCancelled && !anyFailure) {
statusBadge = '![Cancelled](https://img.shields.io/badge/状态-已取消-lightgrey?style=for-the-badge&logo=github-actions&logoColor=white)';
headerTitle = '# ⚪ NapCat 构建已取消';
} else {
statusBadge = '![Failed](https://img.shields.io/badge/状态-构建失败-critical?style=for-the-badge&logo=github-actions&logoColor=white)';
headerTitle = '# ❌ NapCat 构建失败';
}
const downloadLink = (target: BuildTarget) => {
if (target.status !== 'success') return '—';
if (target.downloadUrl) {
return `[📥 下载](${target.downloadUrl})`;
}
return `[📥 下载](${runUrl}#artifacts)`;
};
const lines: string[] = [
COMMENT_MARKER,
'',
'<div align="center">',
'',
headerTitle,
'',
statusBadge,
'',
'</div>',
'',
'---',
'',
'## 📦 构建产物',
'',
'| 包名 | 状态 | 下载 |',
'| :--- | :---: | :---: |',
...targets.map(t => `| \`${t.name}\` | ${getStatusEmoji(t.status)} ${t.status === 'success' ? '成功' : t.status === 'failure' ? '失败' : t.status === 'cancelled' ? '已取消' : '未知'} | ${downloadLink(t)} |`),
'',
'---',
'',
'## 📋 构建信息',
'',
'| 项目 | 值 |',
'| :--- | :--- |',
...(version ? [`| 🏷️ 版本号 | \`${version}\` |`] : []),
`| 📝 提交 | \`${shortSha}\` |`,
`| 🔗 构建日志 | [查看详情](${runUrl}) |`,
`| 🕐 完成时间 | ${time} |`,
];
// 添加快速安装命令
if (installInfo) {
const directUrl = installInfo.installUrl;
const proxyInstallUrl = toProxyUrl(directUrl);
lines.push(
'',
'---',
'',
'## 🚀 快速安装 (Linux)',
'',
'**直连(需要能访问 GitHub)**',
'```bash',
`curl -sSL ${directUrl} | bash`,
'```',
'',
'**加速(国内推荐,ghfast.top)**',
'```bash',
`curl -sSL ${proxyInstallUrl} | bash`,
'```',
'',
`> 📦 [查看 Release 页面](${installInfo.releaseUrl})`,
'> ⚠️ 此为 PR 测试版本,约 5 天后会被自动清理。'
);
} else if (publishFailed) {
lines.push(
'',
'---',
'',
'## ⚠️ Release 发布失败',
'',
'> ❌ PR Release 发布过程中出现错误,无法提供快速安装命令。',
`> 🔗 [查看构建日志](${runUrl}) 了解详情。`
);
}
// 添加错误详情
const failedTargets = targets.filter(t => t.status === 'failure' && t.error);
if (failedTargets.length > 0) {
lines.push('', '---', '', '## ⚠️ 错误详情', '');
for (const target of failedTargets) {
lines.push(
'<details>',
`<summary>🔴 <b>${target.name}</b> 构建错误</summary>`,
'',
'```',
escapeCodeBlock(target.error!),
'```',
'',
'</details>',
''
);
}
}
// 添加底部提示
lines.push('---', '');
if (allSuccess) {
lines.push(
'<div align="center">',
'',
'> 🎉 **所有构建均已成功完成!**',
'>',
'> 点击上方下载链接获取构建产物进行测试',
'',
'</div>'
);
} else if (anyCancelled && !anyFailure) {
lines.push(
'<div align="center">',
'',
'> ⚪ **构建已被取消**',
'>',
'> 可能是由于新的提交触发了新的构建',
'',
'</div>'
);
} else {
lines.push(
'<div align="center">',
'',
'> ⚠️ **部分构建失败**',
'>',
'> 请查看上方错误详情或点击构建日志查看完整输出',
'',
'</div>'
);
}
return lines.join('\n');
}
// ============== Docker 构建评论 ==============
export function generateDockerBuildingComment (prNumber: string, prSha: string): string {
const time = getTimeString();
const shortSha = formatSha(prSha);
const lines: string[] = [
DOCKER_COMMENT_MARKER,
'',
'<div align="center">',
'',
'# 🐳 Docker 测试镜像构建中',
'',
'![Building](https://img.shields.io/badge/Docker-构建中-yellow?style=for-the-badge&logo=docker&logoColor=white)',
'',
'</div>',
'',
'---',
'',
'## 📋 构建信息',
'',
'| 项目 | 值 |',
'| :--- | :--- |',
`| 📝 提交 | \`${shortSha}\` |`,
`| 🔗 PR | #${prNumber} |`,
`| 🕐 触发时间 | ${time} |`,
'',
'---',
'',
'<div align="center">',
'',
'> ⏳ **Docker 镜像正在构建中,请稍候...**',
'>',
'> 构建完成后将自动更新此评论',
'',
'</div>',
];
return lines.join('\n');
}
export function generateDockerResultComment (
prNumber: string,
prSha: string,
success: boolean,
dockerImage?: string,
error?: string
): string {
const time = getTimeString();
const shortSha = formatSha(prSha);
const statusBadge = success
? '![Success](https://img.shields.io/badge/Docker-构建成功-success?style=for-the-badge&logo=docker&logoColor=white)'
: '![Failed](https://img.shields.io/badge/Docker-构建失败-critical?style=for-the-badge&logo=docker&logoColor=white)';
const headerTitle = success ? '# ✅ Docker 测试镜像就绪' : '# ❌ Docker 测试镜像构建失败';
const lines: string[] = [
DOCKER_COMMENT_MARKER,
'',
'<div align="center">',
'',
headerTitle,
'',
statusBadge,
'',
'</div>',
'',
'---',
'',
'## 📋 构建信息',
'',
'| 项目 | 值 |',
'| :--- | :--- |',
`| 📝 提交 | \`${shortSha}\` |`,
`| 🔗 PR | #${prNumber} |`,
`| 🕐 完成时间 | ${time} |`,
];
if (success && dockerImage) {
lines.push(
'',
'---',
'',
'## 🚀 快速使用',
'',
'```bash',
`docker pull ${dockerImage}`,
'```',
'',
'> ⚠️ 此为 PR 测试镜像,保留 5 天后自动清理。'
);
}
if (!success && error) {
lines.push(
'',
'---',
'',
'<details>',
'<summary>🔴 <b>错误详情</b></summary>',
'',
'```',
escapeCodeBlock(error),
'```',
'',
'</details>'
);
}
lines.push('', '---', '');
if (success) {
lines.push(
'<div align="center">',
'',
'> 🎉 **Docker 测试镜像已就绪!**',
'>',
'> 使用上方命令拉取镜像进行测试',
'',
'</div>'
);
} else {
lines.push(
'<div align="center">',
'',
'> ⚠️ **Docker 镜像构建失败**',
'>',
'> 请检查错误详情',
'',
'</div>'
);
}
return lines.join('\n');
}
+321
View File
@@ -0,0 +1,321 @@
/**
* GitHub API 工具库
*/
import { appendFileSync } from 'node:fs';
// ============== 类型定义 ==============
export interface PullRequest {
number: number;
state: string;
head: {
sha: string;
ref: string;
repo: {
full_name: string;
};
};
}
export interface Repository {
owner: {
type: string;
};
}
export interface Artifact {
id: number;
name: string;
size_in_bytes: number;
archive_download_url: string;
}
export interface Release {
id: number;
tag_name: string;
name: string;
upload_url: string;
html_url: string;
created_at: string;
}
export interface ReleaseAsset {
id: number;
name: string;
browser_download_url: string;
}
// ============== GitHub API Client ==========================
export class GitHubAPI {
private token: string;
private baseUrl = 'https://api.github.com';
constructor (token: string) {
this.token = token;
}
private async request<T> (endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
...options.headers,
},
});
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
}
// 204 No Content(DELETE 等)不解析 body
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
async getPullRequest (owner: string, repo: string, pullNumber: number): Promise<PullRequest> {
return this.request<PullRequest>(`/repos/${owner}/${repo}/pulls/${pullNumber}`);
}
async getCollaboratorPermission (owner: string, repo: string, username: string): Promise<string> {
const data = await this.request<{ permission: string; }>(
`/repos/${owner}/${repo}/collaborators/${username}/permission`
);
return data.permission;
}
async getRepository (owner: string, repo: string): Promise<Repository> {
return this.request(`/repos/${owner}/${repo}`);
}
async checkOrgMembership (org: string, username: string): Promise<boolean> {
try {
await this.request(`/orgs/${org}/members/${username}`);
return true;
} catch {
return false;
}
}
async getRunArtifacts (owner: string, repo: string, runId: string): Promise<Artifact[]> {
const data = await this.request<{ artifacts: Artifact[]; }>(
`/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`
);
return data.artifacts;
}
async createComment (owner: string, repo: string, issueNumber: number, body: string): Promise<void> {
await this.request(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
method: 'POST',
body: JSON.stringify({ body }),
headers: { 'Content-Type': 'application/json' },
});
}
async findComment (owner: string, repo: string, issueNumber: number, marker: string): Promise<number | null> {
let page = 1;
const perPage = 100;
while (page <= 10) { // 最多检查 1000 条评论
const comments = await this.request<Array<{ id: number, body: string; }>>(
`/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=${perPage}&page=${page}`
);
if (comments.length === 0) {
return null;
}
const found = comments.find(c => c.body.includes(marker));
if (found) {
return found.id;
}
if (comments.length < perPage) {
return null;
}
page++;
}
return null;
}
async updateComment (owner: string, repo: string, commentId: number, body: string): Promise<void> {
await this.request(`/repos/${owner}/${repo}/issues/comments/${commentId}`, {
method: 'PATCH',
body: JSON.stringify({ body }),
headers: { 'Content-Type': 'application/json' },
});
}
async createOrUpdateComment (
owner: string,
repo: string,
issueNumber: number,
body: string,
marker: string
): Promise<void> {
const existingId = await this.findComment(owner, repo, issueNumber, marker);
if (existingId) {
await this.updateComment(owner, repo, existingId, body);
console.log(`✓ Updated comment #${existingId}`);
} else {
await this.createComment(owner, repo, issueNumber, body);
console.log('✓ Created new comment');
}
}
// ============== Release API ==============
async getReleaseByTag (owner: string, repo: string, tag: string): Promise<Release | null> {
try {
return await this.request<Release>(`/repos/${owner}/${repo}/releases/tags/${tag}`);
} catch {
return null;
}
}
async deleteRelease (owner: string, repo: string, releaseId: number): Promise<void> {
await this.request(`/repos/${owner}/${repo}/releases/${releaseId}`, { method: 'DELETE' });
}
async deleteRef (owner: string, repo: string, ref: string): Promise<void> {
try {
await this.request(`/repos/${owner}/${repo}/git/refs/${ref}`, { method: 'DELETE' });
} catch {
// ref may not exist, ignore
}
}
async createRelease (
owner: string,
repo: string,
tag: string,
name: string,
body: string,
prerelease = true
): Promise<Release> {
return this.request<Release>(`/repos/${owner}/${repo}/releases`, {
method: 'POST',
body: JSON.stringify({ tag_name: tag, name, body, prerelease }),
headers: { 'Content-Type': 'application/json' },
});
}
async uploadReleaseAsset (
uploadUrl: string,
fileName: string,
data: Buffer,
contentType = 'application/octet-stream'
): Promise<ReleaseAsset> {
const url = uploadUrl.replace(/\{[^}]*\}/g, '') + `?name=${encodeURIComponent(fileName)}`;
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/vnd.github+json',
'Content-Type': contentType,
'X-GitHub-Api-Version': '2022-11-28',
},
body: data,
});
if (!response.ok) {
throw new Error(`Upload ${fileName} failed: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<ReleaseAsset>;
}
// ============== Dispatch API ==============
async repositoryDispatch (
owner: string,
repo: string,
eventType: string,
clientPayload: Record<string, string>
): Promise<void> {
const response = await fetch(`${this.baseUrl}/repos/${owner}/${repo}/dispatches`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'Content-Type': 'application/json',
},
body: JSON.stringify({ event_type: eventType, client_payload: clientPayload }),
});
if (!response.ok && response.status !== 204) {
throw new Error(`Dispatch failed: ${response.status} ${response.statusText}`);
}
}
async createWorkflowDispatch (
owner: string,
repo: string,
workflowFile: string,
ref: string,
inputs: Record<string, string>
): Promise<void> {
const response = await fetch(`${this.baseUrl}/repos/${owner}/${repo}/actions/workflows/${workflowFile}/dispatches`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'Content-Type': 'application/json',
},
body: JSON.stringify({ ref, inputs }),
});
if (!response.ok && response.status !== 204) {
throw new Error(`Workflow dispatch failed: ${response.status} ${response.statusText}`);
}
}
// ============== Release Cleanup API ==============
async listReleases (owner: string, repo: string, perPage = 100, page = 1): Promise<Release[]> {
return this.request<Release[]>(
`/repos/${owner}/${repo}/releases?per_page=${perPage}&page=${page}`
);
}
}
// ============== Output 工具 ==============
export function setOutput (name: string, value: string): void {
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
appendFileSync(outputFile, `${name}=${value}\n`);
}
console.log(` ${name}=${value}`);
}
export function setMultilineOutput (name: string, value: string): void {
const outputFile = process.env.GITHUB_OUTPUT;
if (outputFile) {
const delimiter = `EOF_${Date.now()}`;
appendFileSync(outputFile, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
}
}
// ============== 环境变量工具 ==============
// Note: 不使用函数重载——Node.js --experimental-strip-types 不支持 TS overload signatures
export function getEnv (name: string, required = false): string | undefined {
const value = process.env[name];
if (required && !value) {
throw new Error(`Environment variable ${name} is required`);
}
return value;
}
export function getRepository (): { owner: string, repo: string; } {
const repository = getEnv('GITHUB_REPOSITORY', true)!;
const [owner, repo] = repository.split('/');
return { owner, repo };
}
+36
View File
@@ -0,0 +1,36 @@
/**
* PR Build - 更新构建中状态评论
*
* 环境变量:
* - GITHUB_TOKEN: GitHub API Token
* - PR_NUMBER: PR 编号
* - PR_SHA: PR 提交 SHA
*/
import { GitHubAPI, getEnv, getRepository } from './lib/github.ts';
import { generateBuildingComment, COMMENT_MARKER } from './lib/comment.ts';
const BUILD_TARGETS = ['NapCat.Framework', 'NapCat.Shell'];
async function main (): Promise<void> {
console.log('🔨 Updating building status comment\n');
const token = getEnv('GITHUB_TOKEN', true);
const prNumber = parseInt(getEnv('PR_NUMBER', true), 10);
const prSha = getEnv('PR_SHA', true);
const { owner, repo } = getRepository();
console.log(`PR: #${prNumber}`);
console.log(`SHA: ${prSha}`);
console.log(`Repo: ${owner}/${repo}\n`);
const github = new GitHubAPI(token);
const comment = generateBuildingComment(prSha, BUILD_TARGETS);
await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
}
main().catch((error) => {
console.error('❌ Error:', error);
process.exit(1);
});
+206
View File
@@ -0,0 +1,206 @@
/**
* PR Build Check Script
* 检查 PR 构建触发条件和用户权限
*
* 环境变量:
* - GITHUB_TOKEN: GitHub API Token
* - GITHUB_EVENT_NAME: 事件名称
* - GITHUB_EVENT_PATH: 事件 payload 文件路径
* - GITHUB_REPOSITORY: 仓库名称 (owner/repo)
* - GITHUB_OUTPUT: 输出文件路径
*/
import { readFileSync } from 'node:fs';
import { GitHubAPI, getEnv, getRepository, setOutput } from './lib/github.ts';
import type { PullRequest } from './lib/github.ts';
// ============== 类型定义 ==============
interface GitHubPayload {
pull_request?: PullRequest;
issue?: {
number: number;
pull_request?: object;
};
comment?: {
body: string;
user: { login: string; };
};
}
interface CheckResult {
should_build: boolean;
pr_number?: number;
pr_sha?: string;
pr_head_repo?: string;
pr_head_ref?: string;
}
// ============== 权限检查 ==============
async function checkUserPermission (
github: GitHubAPI,
owner: string,
repo: string,
username: string
): Promise<boolean> {
// 方法1:检查仓库协作者权限
try {
const permission = await github.getCollaboratorPermission(owner, repo, username);
if (['admin', 'write', 'maintain'].includes(permission)) {
console.log(`✓ User ${username} has ${permission} permission`);
return true;
}
console.log(`✗ User ${username} has ${permission} permission (insufficient)`);
} catch (e) {
console.log(`✗ Failed to get collaborator permission: ${(e as Error).message}`);
}
// 方法2:检查组织成员身份
try {
const repoInfo = await github.getRepository(owner, repo);
if (repoInfo.owner.type === 'Organization') {
const isMember = await github.checkOrgMembership(owner, username);
if (isMember) {
console.log(`✓ User ${username} is organization member`);
return true;
}
console.log(`✗ User ${username} is not organization member`);
}
} catch (e) {
console.log(`✗ Failed to check org membership: ${(e as Error).message}`);
}
return false;
}
// ============== 事件处理 ==============
function handlePullRequestTarget (payload: GitHubPayload): CheckResult {
const pr = payload.pull_request;
if (!pr) {
console.log('✗ No pull_request in payload');
return { should_build: false };
}
if (pr.state !== 'open') {
console.log(`✗ PR is not open (state: ${pr.state})`);
return { should_build: false };
}
console.log(`✓ PR #${pr.number} is open, triggering build`);
return {
should_build: true,
pr_number: pr.number,
pr_sha: pr.head.sha,
pr_head_repo: pr.head.repo.full_name,
pr_head_ref: pr.head.ref,
};
}
async function handleIssueComment (
payload: GitHubPayload,
github: GitHubAPI,
owner: string,
repo: string
): Promise<CheckResult> {
const { issue, comment } = payload;
if (!issue || !comment) {
console.log('✗ No issue or comment in payload');
return { should_build: false };
}
// 检查是否是 PR 的评论
if (!issue.pull_request) {
console.log('✗ Comment is not on a PR');
return { should_build: false };
}
// 检查是否是 /build 命令
if (!comment.body.trim().startsWith('/build')) {
console.log('✗ Comment is not a /build command');
return { should_build: false };
}
console.log(`→ /build command from @${comment.user.login}`);
// 获取 PR 详情
const pr = await github.getPullRequest(owner, repo, issue.number);
// 检查 PR 状态
if (pr.state !== 'open') {
console.log(`✗ PR is not open (state: ${pr.state})`);
await github.createComment(owner, repo, issue.number, '⚠️ 此 PR 已关闭,无法触发构建。');
return { should_build: false };
}
// 检查用户权限
const username = comment.user.login;
const hasPermission = await checkUserPermission(github, owner, repo, username);
if (!hasPermission) {
console.log(`✗ User ${username} has no permission`);
await github.createComment(
owner,
repo,
issue.number,
`⚠️ @${username} 您没有权限使用 \`/build\` 命令,仅仓库协作者或组织成员可使用。`
);
return { should_build: false };
}
console.log(`✓ Build triggered by @${username}`);
return {
should_build: true,
pr_number: issue.number,
pr_sha: pr.head.sha,
pr_head_repo: pr.head.repo.full_name,
pr_head_ref: pr.head.ref,
};
}
// ============== 主函数 ==============
async function main (): Promise<void> {
console.log('🔍 PR Build Check\n');
const token = getEnv('GITHUB_TOKEN', true);
const eventName = getEnv('GITHUB_EVENT_NAME', true);
const eventPath = getEnv('GITHUB_EVENT_PATH', true);
const { owner, repo } = getRepository();
console.log(`Event: ${eventName}`);
console.log(`Repository: ${owner}/${repo}\n`);
const payload = JSON.parse(readFileSync(eventPath, 'utf-8')) as GitHubPayload;
const github = new GitHubAPI(token);
let result: CheckResult;
switch (eventName) {
case 'pull_request_target':
result = handlePullRequestTarget(payload);
break;
case 'issue_comment':
result = await handleIssueComment(payload, github, owner, repo);
break;
default:
console.log(`✗ Unsupported event: ${eventName}`);
result = { should_build: false };
}
// 输出结果
console.log('\n=== Outputs ===');
setOutput('should_build', String(result.should_build));
setOutput('pr_number', String(result.pr_number ?? ''));
setOutput('pr_sha', result.pr_sha ?? '');
setOutput('pr_head_repo', result.pr_head_repo ?? '');
setOutput('pr_head_ref', result.pr_head_ref ?? '');
}
main().catch((error) => {
console.error('❌ Error:', error);
process.exit(1);
});
+50
View File
@@ -0,0 +1,50 @@
/**
* PR Build - Dispatch untrusted build workflow
*
* 环境变量:
* - GITHUB_TOKEN: GitHub API Token
* - PR_NUMBER: PR 编号
* - PR_SHA: PR HEAD SHA
* - PR_HEAD_REPO: PR 源仓库
* - PR_HEAD_REF: PR 源分支
* - WORKFLOW_FILE: 工作流文件名,默认 build-pr.yml
* - DISPATCH_REF: 触发分支,默认当前分支
*/
import { GitHubAPI, getEnv, getRepository } from './lib/github.ts';
declare const process: { exit(code?: number): never; };
async function main (): Promise<void> {
console.log('🚀 Dispatching PR build workflow\n');
const token = getEnv('GITHUB_TOKEN', true)!;
const prNumber = getEnv('PR_NUMBER', true)!;
const prSha = getEnv('PR_SHA', true)!;
const prHeadRepo = getEnv('PR_HEAD_REPO', true)!;
const prHeadRef = getEnv('PR_HEAD_REF') || '';
const workflowFile = getEnv('WORKFLOW_FILE') || 'build-pr.yml';
const dispatchRef = getEnv('DISPATCH_REF') || getEnv('GITHUB_REF_NAME') || 'main';
const { owner, repo } = getRepository();
console.log(`PR: #${prNumber}`);
console.log(`SHA: ${prSha}`);
console.log(`Head repo: ${prHeadRepo}`);
console.log(`Workflow: ${workflowFile}`);
console.log(`Ref: ${dispatchRef}\n`);
const github = new GitHubAPI(token);
await github.createWorkflowDispatch(owner, repo, workflowFile, dispatchRef, {
pr_number: prNumber,
pr_sha: prSha,
pr_head_repo: prHeadRepo,
pr_head_ref: prHeadRef,
});
console.log('✅ Dispatch created');
}
main().catch((error) => {
console.error('❌ Error:', error);
process.exit(1);
});
+107
View File
@@ -0,0 +1,107 @@
/**
* PR Build - 更新构建结果评论
*
* 环境变量:
* - GITHUB_TOKEN: GitHub API Token
* - PR_NUMBER: PR 编号
* - PR_SHA: PR 提交 SHA
* - RUN_ID: GitHub Actions Run ID
* - NAPCAT_VERSION: 构建版本号
* - FRAMEWORK_STATUS: Framework 构建状态
* - FRAMEWORK_ERROR: Framework 构建错误信息
* - SHELL_STATUS: Shell 构建状态
* - SHELL_ERROR: Shell 构建错误信息
* - INSTALL_URL: PR Release 安装脚本 URL (可选)
* - RELEASE_URL: PR Release 页面 URL (可选)
*/
import { GitHubAPI, getEnv, getRepository } from './lib/github.ts';
import { generateResultComment, COMMENT_MARKER } from './lib/comment.ts';
import type { BuildTarget, BuildStatus, InstallInfo } from './lib/comment.ts';
function parseStatus (value: string | undefined): BuildStatus {
if (value === 'success' || value === 'failure' || value === 'cancelled') {
return value;
}
return 'unknown';
}
async function main (): Promise<void> {
console.log('📝 Updating build result comment\n');
const token = getEnv('GITHUB_TOKEN', true);
const prNumber = parseInt(getEnv('PR_NUMBER', true), 10);
const prSha = getEnv('PR_SHA') || 'unknown';
const runId = getEnv('RUN_ID', true);
const version = getEnv('NAPCAT_VERSION') || '';
const { owner, repo } = getRepository();
const frameworkStatus = parseStatus(getEnv('FRAMEWORK_STATUS'));
const frameworkError = getEnv('FRAMEWORK_ERROR');
const shellStatus = parseStatus(getEnv('SHELL_STATUS'));
const shellError = getEnv('SHELL_ERROR');
console.log(`PR: #${prNumber}`);
console.log(`SHA: ${prSha}`);
console.log(`Version: ${version}`);
console.log(`Run: ${runId}`);
console.log(`Framework: ${frameworkStatus}${frameworkError ? ` (${frameworkError})` : ''}`);
console.log(`Shell: ${shellStatus}${shellError ? ` (${shellError})` : ''}\n`);
// PR Release 信息(由 publish-pr-release job 传入)
const installUrl = getEnv('INSTALL_URL');
const releaseUrl = getEnv('RELEASE_URL');
const publishStatus = getEnv('PUBLISH_STATUS'); // 'success' | 'failure' | 'skipped' | ''
const installInfo: InstallInfo | undefined =
installUrl && releaseUrl ? { installUrl, releaseUrl } : undefined;
const publishFailed = publishStatus === 'failure';
if (installInfo) {
console.log(`Install URL: ${installUrl}`);
console.log(`Release URL: ${releaseUrl}\n`);
} else if (publishFailed) {
console.log('Publish status: FAILED\n');
}
const github = new GitHubAPI(token);
const repository = `${owner}/${repo}`;
// 获取 artifacts 列表,生成直接下载链接
const artifactMap: Record<string, string> = {};
try {
const artifacts = await github.getRunArtifacts(owner, repo, runId);
console.log(`Found ${artifacts.length} artifacts`);
for (const artifact of artifacts) {
// 生成直接下载链接:https://github.com/{owner}/{repo}/actions/runs/{run_id}/artifacts/{artifact_id}
const downloadUrl = `https://github.com/${repository}/actions/runs/${runId}/artifacts/${artifact.id}`;
artifactMap[artifact.name] = downloadUrl;
console.log(` - ${artifact.name}: ${downloadUrl}`);
}
} catch (e) {
console.log(`Warning: Failed to get artifacts: ${(e as Error).message}`);
}
const targets: BuildTarget[] = [
{
name: 'NapCat.Framework',
status: frameworkStatus,
error: frameworkError,
downloadUrl: artifactMap['NapCat.Framework'],
},
{
name: 'NapCat.Shell',
status: shellStatus,
error: shellError,
downloadUrl: artifactMap['NapCat.Shell'],
},
];
const comment = generateResultComment(targets, prSha, runId, repository, version, installInfo, publishFailed);
await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
}
main().catch((error) => {
console.error('❌ Error:', error);
process.exit(1);
});
+145
View File
@@ -0,0 +1,145 @@
/**
* PR Build Runner
* 执行构建步骤
*
* 用法: node pr-build-run.ts <target>
* target: framework | shell
*/
import { execSync } from 'node:child_process';
import { existsSync, renameSync, unlinkSync } from 'node:fs';
import { setOutput } from './lib/github.ts';
type BuildTarget = 'framework' | 'shell';
interface BuildStep {
name: string;
command: string;
errorMessage: string;
}
// ============== 构建步骤 ==============
function getCommonSteps (): BuildStep[] {
return [
// pnpm 已通过 GitHub Action (pnpm/action-setup) 预安装并固定 v9, 此处无需再次安装
{
name: 'Install dependencies',
command: 'pnpm i',
errorMessage: 'Failed to install dependencies',
},
{
name: 'Type check',
command: 'pnpm run typecheck',
errorMessage: 'Type check failed',
},
{
name: 'Test',
command: 'pnpm test',
errorMessage: 'Tests failed',
},
{
name: 'Build WebUI',
command: 'pnpm --filter napcat-webui-frontend run build',
errorMessage: 'WebUI build failed',
},
];
}
function getTargetSteps (target: BuildTarget): BuildStep[] {
if (target === 'framework') {
return [
{
name: 'Build Framework',
command: 'pnpm run build:framework',
errorMessage: 'Framework build failed',
},
];
}
return [
{
name: 'Build Shell',
command: 'pnpm run build:shell',
errorMessage: 'Shell build failed',
},
];
}
// ============== 执行器 ==============
function runStep (step: BuildStep): boolean {
console.log(`\n::group::${step.name}`);
console.log(`> ${step.command}\n`);
try {
execSync(step.command, {
stdio: 'inherit',
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash',
});
console.log('::endgroup::');
console.log(`✓ ${step.name}`);
return true;
} catch (_error) {
console.log('::endgroup::');
console.log(`✗ ${step.name}`);
setOutput('error', step.errorMessage);
return false;
}
}
function postBuild (target: BuildTarget): void {
const srcDir = target === 'framework'
? 'packages/napcat-framework/dist'
: 'packages/napcat-shell/dist';
const destDir = target === 'framework' ? 'framework-dist' : 'shell-dist';
console.log(`\n→ Moving ${srcDir} to ${destDir}`);
if (!existsSync(srcDir)) {
throw new Error(`Build output not found: ${srcDir}`);
}
renameSync(srcDir, destDir);
// Install production dependencies
console.log('→ Installing production dependencies');
execSync('npm install --omit=dev', {
cwd: destDir,
stdio: 'inherit',
shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash',
});
// Remove package-lock.json
const lockFile = `${destDir}/package-lock.json`;
if (existsSync(lockFile)) {
unlinkSync(lockFile);
}
console.log(`✓ Build output ready at ${destDir}`);
}
// ============== 主函数 ==============
function main (): void {
const target = process.argv[2] as BuildTarget;
if (!target || !['framework', 'shell'].includes(target)) {
console.error('Usage: node pr-build-run.ts <framework|shell>');
process.exit(1);
}
console.log(`🔨 Building NapCat.${target === 'framework' ? 'Framework' : 'Shell'}\n`);
const steps = [...getCommonSteps(), ...getTargetSteps(target)];
for (const step of steps) {
if (!runStep(step)) {
process.exit(1);
}
}
postBuild(target);
console.log('\n✅ Build completed successfully!');
}
main();
+69
View File
@@ -0,0 +1,69 @@
/**
* PR Build - 触发 NapCat-Docker 仓库构建 PR 测试镜像
*
* 功能:
* 1. 在 PR 中创建 Docker 构建中评论
* 2. 触发 NapCat-Docker 仓库的 repository_dispatch 事件
*
* 环境变量:
* DISPATCH_PAT - 具备目标仓库 dispatch 权限的 PAT
* GITHUB_TOKEN - GitHub API Token (用于写 PR 评论)
* PR_NUMBER - PR 编号
* PR_SHA - PR HEAD 提交 SHA
* PR_HEAD_REPO - PR 源仓库 (owner/repo)
* SOURCE_REPO - 当前仓库 (默认 NapNeko/NapCatQQ)
* RELEASE_REPO - PR release 仓库 (默认 NapNeko/napcat-pr-release)
* TARGET_REPO - Docker 仓库 (默认 NapNeko/NapCat-Docker)
*/
import { GitHubAPI, getEnv } from './lib/github.ts';
import { generateDockerBuildingComment, DOCKER_COMMENT_MARKER } from './lib/comment.ts';
async function main (): Promise<void> {
console.log('🐳 Dispatching Docker build\n');
const token = getEnv('DISPATCH_PAT', true);
const githubToken = getEnv('GITHUB_TOKEN', true);
const prNumber = getEnv('PR_NUMBER', true);
const prSha = getEnv('PR_SHA', true);
const prHeadRepo = getEnv('PR_HEAD_REPO') || '';
const sourceRepo = getEnv('SOURCE_REPO') || 'NapNeko/NapCatQQ';
const releaseRepo = getEnv('RELEASE_REPO') || 'NapNeko/napcat-pr-release';
const targetRepo = getEnv('TARGET_REPO') || 'NapNeko/NapCat-Docker';
const [targetOwner, targetRepoName] = targetRepo.split('/');
const [sourceOwner, sourceRepoName] = sourceRepo.split('/');
const shortSha = prSha.substring(0, 7);
console.log(` PR: #${prNumber}`);
console.log(` SHA: ${shortSha}`);
console.log(` Target: ${targetRepo}`);
console.log(` Release: ${releaseRepo}\n`);
// 在 PR 中创建 Docker 构建中评论
console.log('💬 Creating Docker building comment...');
const prGithub = new GitHubAPI(githubToken);
const dockerComment = generateDockerBuildingComment(prNumber, prSha);
await prGithub.createOrUpdateComment(
sourceOwner, sourceRepoName, parseInt(prNumber, 10), dockerComment, DOCKER_COMMENT_MARKER
);
// 触发 Docker 仓库构建
const github = new GitHubAPI(token);
await github.repositoryDispatch(targetOwner, targetRepoName, 'napcatqq-pr-build', {
pr_number: prNumber,
pr_sha: prSha,
pr_short_sha: shortSha,
pr_head_repo: prHeadRepo,
source_repo: sourceRepo,
release_repo: releaseRepo,
});
console.log('✅ Dispatch succeeded');
}
main().catch((error) => {
console.error('❌ Error:', error);
process.exit(1);
});
+173
View File
@@ -0,0 +1,173 @@
/**
* PR Build - 发布构建产物到 napcat-pr-release 仓库
*
* 功能:
* 1. 将 CI 构建产物(Framework / Shell)打包为 zip
* 2. 从 NapCat-Installer 仓库拉取 install.sh 并替换下载链接
* 3. 发布到 napcat-pr-release 仓库的 GitHub Release
*
* 环境变量:
* RELEASE_PAT - 具备目标仓库 release 写权限的 PAT
* PR_NUMBER - PR 编号
* PR_SHA - PR HEAD 提交 SHA
* NAPCAT_VERSION - 构建版本号
* ARTIFACTS_DIR - 构建产物目录 (默认 ./artifacts)
* RELEASE_REPO - 发布仓库 (默认 NapNeko/napcat-pr-release)
* INSTALLER_REPO - 安装脚本仓库 (默认 NapNeko/NapCat-Installer)
* INSTALLER_BRANCH - 安装脚本分支 (默认 main)
*/
import { execSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { GitHubAPI, getEnv, setOutput } from './lib/github.ts';
import { toProxyUrl } from './lib/comment.ts';
interface AssetEntry {
name: string;
path: string;
contentType: string;
}
async function main (): Promise<void> {
console.log('📦 PR Release Publisher\n');
const token = getEnv('RELEASE_PAT', true);
const prNumber = getEnv('PR_NUMBER', true);
const prSha = getEnv('PR_SHA', true);
const version = getEnv('NAPCAT_VERSION') || 'unknown';
const artifactsDir = getEnv('ARTIFACTS_DIR') || './artifacts';
const releaseRepo = getEnv('RELEASE_REPO') || 'NapNeko/napcat-pr-release';
const installerRepo = getEnv('INSTALLER_REPO') || 'NapNeko/NapCat-Installer';
const installerBranch = getEnv('INSTALLER_BRANCH') || 'main';
const [releaseOwner, releaseRepoName] = releaseRepo.split('/');
const shortSha = prSha.substring(0, 7);
const tag = `pr-${prNumber}-${shortSha}`;
const releaseName = `PR #${prNumber} (${shortSha})`;
const downloadBase = `https://github.com/${releaseRepo}/releases/download/${tag}`;
console.log(` Tag: ${tag}`);
console.log(` Repo: ${releaseRepo}`);
console.log(` Version: ${version}`);
console.log(` SHA: ${shortSha}\n`);
const github = new GitHubAPI(token);
const releaseDir = resolve('./release-assets'); // 使用绝对路径,避免 zip cwd 切换后相对路径解析错误
mkdirSync(releaseDir, { recursive: true });
// ---- 打包构建产物 ----
const artifacts = ['NapCat.Framework', 'NapCat.Shell'];
for (const name of artifacts) {
const dir = join(artifactsDir, name);
if (existsSync(dir)) {
const zipPath = join(releaseDir, `${name}.zip`);
console.log(`📦 Packing ${name}...`);
execSync(`zip -qr "${zipPath}" .`, { cwd: dir, stdio: 'inherit' });
console.log(` ✓ ${name}.zip\n`);
} else {
console.log(` ⚠ ${name} 目录不存在,跳过\n`);
}
}
// ---- 拉取并修改安装脚本 ----
console.log('📜 Fetching install.sh...');
const scriptUrl = `https://raw.githubusercontent.com/${installerRepo}/${installerBranch}/script/install.sh`;
try {
const response = await fetch(scriptUrl);
if (response.ok) {
let script = await response.text();
// 替换下载链接(保留代理前缀 ${target_proxy:+${target_proxy}/})
const shellUrl = `${downloadBase}/NapCat.Shell.zip`;
const originalScript = script;
script = script.replace(
/napcat_download_url="\$\{target_proxy:\+\$\{target_proxy\}\/\}https:\/\/github\.com\/NapNeko\/NapCatQQ\/releases\/latest\/download\/NapCat\.Shell\.zip"/g,
`napcat_download_url="\${target_proxy:+\${target_proxy}/}${shellUrl}"`
);
if (script === originalScript) {
console.log(' ⚠ Warning: URL replacement did not match - install.sh format may have changed');
}
const scriptPath = join(releaseDir, 'install.sh');
writeFileSync(scriptPath, script, { mode: 0o755 });
console.log(` ✓ Patched download URL -> ${shellUrl}\n`);
} else {
console.log(` ⚠ Failed to fetch install.sh: ${response.status}\n`);
}
} catch (e) {
console.log(` ⚠ Failed to fetch install.sh: ${(e as Error).message}\n`);
}
// ---- 收集所有要上传的文件 ----
const assets: AssetEntry[] = [];
for (const file of readdirSync(releaseDir)) {
const ext = file.split('.').pop()?.toLowerCase();
const contentType = ext === 'zip' ? 'application/zip' : ext === 'sh' ? 'application/x-sh' : 'application/octet-stream';
assets.push({ name: file, path: join(releaseDir, file), contentType });
}
console.log(`📋 Assets to upload: ${assets.map(a => a.name).join(', ')}\n`);
// ---- 删除旧 release ----
console.log('🗑️ Checking for existing release...');
const existing = await github.getReleaseByTag(releaseOwner, releaseRepoName, tag);
if (existing) {
console.log(` Deleting existing release (id=${existing.id})...`);
await github.deleteRelease(releaseOwner, releaseRepoName, existing.id);
await github.deleteRef(releaseOwner, releaseRepoName, `tags/${tag}`);
console.log(' ✓ Deleted\n');
} else {
console.log(' No existing release\n');
}
// ---- 创建 Release ----
const directInstallUrl = `${downloadBase}/install.sh`;
const proxyInstallUrl = toProxyUrl(directInstallUrl);
const releaseBody = [
`## NapCat PR #${prNumber} 测试版本`,
'',
`- **版本**: \`${version}\``,
`- **提交**: \`${prSha}\``,
`- **来源**: [NapCatQQ PR #${prNumber}](https://github.com/NapNeko/NapCatQQ/pull/${prNumber})`,
'',
'### 快速安装 (Linux)',
'',
'**直连(需要能访问 GitHub)**',
'```bash',
`curl -sSL ${directInstallUrl} | bash`,
'```',
'',
'**加速(国内推荐,ghfast.top)**',
'```bash',
`curl -sSL ${proxyInstallUrl} | bash`,
'```',
'',
'> ⚠️ 此为 PR 测试版本,约 5 天后会被自动清理。',
].join('\n');
console.log('🚀 Creating release...');
const release = await github.createRelease(
releaseOwner, releaseRepoName, tag, releaseName, releaseBody, true
);
console.log(` ✓ Release created: ${release.html_url}\n`);
// ---- 上传资产 ----
console.log('📤 Uploading assets...');
for (const asset of assets) {
const data = readFileSync(asset.path);
const uploaded = await github.uploadReleaseAsset(
release.upload_url, asset.name, data, asset.contentType
);
console.log(` ✓ ${asset.name} -> ${uploaded.browser_download_url}`);
}
console.log(`\n✅ Release published: ${release.html_url}`);
// 输出供后续 job 使用
setOutput('release_url', release.html_url);
setOutput('release_tag', tag);
setOutput('install_url', `${downloadBase}/install.sh`);
}
main().catch((error) => {
console.error('❌ Error:', error);
process.exit(1);
});
+80
View File
@@ -0,0 +1,80 @@
/**
* publish-release-hash.mjs
*
* 读取 release.json(不存在则创建),追加新版本的 Shell/Framework napcat.mjs SHA512,
* 保留最近 100 条后写回文件。
*
* 用法:
* node .github/scripts/publish-release-hash.mjs \
* --version v4.9.0 \
* --shell ./artifacts/NapCat.Shell/napcat.mjs \
* --framework ./artifacts/NapCat.Framework/napcat.mjs \
* --file ./hash-repo/release.json
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
// ─── CLI 参数解析 ────────────────────────────────────────────────────────────
function parseArgs () {
const args = process.argv.slice(2);
const result = {};
for (let i = 0; i < args.length; i += 2) {
result[args[i].replace(/^--/, '')] = args[i + 1];
}
return result;
}
const { version, shell: shellPath, framework: frameworkPath, file: outputFile } = parseArgs();
if (!version || !shellPath || !frameworkPath || !outputFile) {
console.error('Usage: node publish-release-hash.mjs --version <v> --shell <path> --framework <path> --file <path>');
process.exit(1);
}
// ─── 工具函数 ────────────────────────────────────────────────────────────────
function sha512File (filePath) {
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
const buf = fs.readFileSync(filePath);
return crypto.createHash('sha512').update(buf).digest('hex');
}
// ─── 主逻辑 ──────────────────────────────────────────────────────────────────
const MAX_ENTRIES = 100;
// 1. 计算哈希
console.log(`Computing SHA512 for Shell: ${shellPath}`);
const shellHash = sha512File(shellPath);
console.log(`Computing SHA512 for Framework: ${frameworkPath}`);
const frameworkHash = sha512File(frameworkPath);
// 2. 读取现有 release.json,不存在则创建空数组
let entries = [];
if (fs.existsSync(outputFile)) {
entries = JSON.parse(fs.readFileSync(outputFile, 'utf-8'));
console.log(`Loaded existing ${outputFile} with ${entries.length} entries.`);
} else {
console.log(`${outputFile} not found, will create it.`);
}
// 3. 追加新条目,去除同版本旧条目,滚动保留最近 MAX_ENTRIES 条
const newEntry = {
version,
shell: { sha512: shellHash },
framework: { sha512: frameworkHash },
updatedAt: new Date().toISOString(),
};
const updated = [
...entries.filter(e => e.version !== version),
newEntry,
].slice(-MAX_ENTRIES);
// 4. 写回文件
fs.writeFileSync(outputFile, JSON.stringify(updated, null, 2) + '\n', 'utf-8');
console.log(`✅ Written ${updated.length} entries to ${outputFile}`);