chore: 将维护脚本迁移到 Bash

This commit is contained in:
sunlei 2026-07-16 13:17:41 +08:00
parent e2be439d17
commit 22d99d1a85
19 changed files with 1463 additions and 589 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
*.sh text eol=lf

20
API.md
View File

@ -387,12 +387,12 @@ NapCat Chinese Desktop Runtime 使用 KT `NapCatQQ` fork 源码构建出的 `Nap
API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,且 `fork-artifact.json` 必须包含完整 marker metadataupstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。NapCat base image 在 release evidence 中必须 pin 到 digest。API Jenkins 只做显式参数推广,不负责自动合并上游、自动构建运行时镜像或在 override 为空时隐式改写 NapCat env。
```powershell
node scripts/napcat-desktop-cn-stage-build.mjs `
--napcat-root D:\MyFiles\KT\GitHub\NapCatQQ `
--upstream-release-tag v4.8.0 `
--upstream-release-commit 0000000000000000000000000000000000000000 `
--napcat-base-image-digest mlikiowa/napcat-docker@sha256:0000000000000000000000000000000000000000000000000000000000000000 `
```bash
node scripts/napcat-desktop-cn-stage-build.mjs \
--napcat-root /home/yemu2/KT/GitHub/NapCatQQ \
--upstream-release-tag v4.8.0 \
--upstream-release-commit 0000000000000000000000000000000000000000 \
--napcat-base-image-digest mlikiowa/napcat-docker@sha256:0000000000000000000000000000000000000000000000000000000000000000 \
--jenkins-build-url https://jenkins.kwitsukasa.top/job/KT-NapCatQQ-Runtime-Release/1/
```
@ -404,7 +404,7 @@ Gateway 只改写 NapCat HTML/JS/CSS 中需要浏览器直连的绝对根路径
必需环境变量:`NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL`、`NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL`、`NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET`、`NAPCAT_WEBUI_GATEWAY_REDIS_HOST`、`NAPCAT_WEBUI_GATEWAY_REDIS_PORT`、`NAPCAT_WEBUI_GATEWAY_SESSION_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_TICKET_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_UPSTREAM_TIMEOUT_MS`。生产 `NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET` 只来自 Jenkins 私有 `.env.production` 生成的 `kt-template-online-api-env` Secret不写入 Git 或 manifest 字面量。
部署验收使用:`pnpm exec jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand`、`pnpm run typecheck`、`pnpm run build`、`Test-Path .\dist\apps\napcat-webui-gateway\main.js`、`git diff --check`。安全验收要求浏览器永远不接收 WebUI token、Credential、上游 URL/端口、Docker 拓扑、Redis 地址或内部 secret。
部署验收使用:`pnpm exec jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand`、`pnpm run typecheck`、`pnpm run build`、`test -f dist/apps/napcat-webui-gateway/main.js`、`git diff --check`。安全验收要求浏览器永远不接收 WebUI token、Credential、上游 URL/端口、Docker 拓扑、Redis 地址或内部 secret。
`napcat_login_event` 实体和表仅作为历史 schema 兼容保留watchdog 不再写入 quick/password 恢复事件,也不再依赖该表判断是否恢复登录。
@ -630,9 +630,9 @@ pnpm test
BangDream 图片 smoke
```powershell
.\scripts\bangdream-render-smoke.ps1 -OperationKey bangdream.song.search -Text "夏祭り" -OutFile ".kt-workspace/bangdream-smoke/song.jpg"
.\scripts\bangdream-render-smoke.ps1 -OperationKey bangdream.event.stage -Text "310" -OutFile ".kt-workspace/bangdream-smoke/stage.jpg" -ExpectedImageCount 5
```bash
bash scripts/bangdream-render-smoke.sh --operation-key bangdream.song.search --text "夏祭り" --out-file ".kt-workspace/bangdream-smoke/song.jpg"
bash scripts/bangdream-render-smoke.sh --operation-key bangdream.event.stage --text "310" --out-file ".kt-workspace/bangdream-smoke/stage.jpg" --expected-image-count 5
```
Jenkins/K8s 发布后还需要观察 rollout、新 Pod 日志,并跑真实运行态 smoke推送成功不等于发布完成。

View File

@ -83,16 +83,16 @@ NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布
运行时发布时API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。
```powershell
node scripts/napcat-desktop-cn-stage-build.mjs `
--napcat-root D:\MyFiles\KT\GitHub\NapCatQQ `
--upstream-release-tag v4.8.0 `
--upstream-release-commit 0000000000000000000000000000000000000000 `
--napcat-base-image-digest mlikiowa/napcat-docker@sha256:0000000000000000000000000000000000000000000000000000000000000000 `
```bash
node scripts/napcat-desktop-cn-stage-build.mjs \
--napcat-root /home/yemu2/KT/GitHub/NapCatQQ \
--upstream-release-tag v4.8.0 \
--upstream-release-commit 0000000000000000000000000000000000000000 \
--napcat-base-image-digest mlikiowa/napcat-docker@sha256:0000000000000000000000000000000000000000000000000000000000000000 \
--jenkins-build-url https://jenkins.kwitsukasa.top/job/KT-NapCatQQ-Runtime-Release/1/
```
NapCat WebUI Gateway 是独立运行的 NestJS 入口,生产镜像使用 `dockerfile.gateway` 打包 `dist/apps/napcat-webui-gateway/main.js` 并监听 `48086`。API 通过内部地址 `NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL=http://kt-napcat-webui-gateway:48086` 创建/续期/撤销 WebUI 会话Admin 浏览器只访问公开前缀 `NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL=/napcat-webui`。Gateway 运行时需要 `NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET`、`NAPCAT_WEBUI_GATEWAY_REDIS_HOST`、`NAPCAT_WEBUI_GATEWAY_REDIS_PORT`、`NAPCAT_WEBUI_GATEWAY_SESSION_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_TICKET_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_UPSTREAM_TIMEOUT_MS`;生产 secret 由 Jenkins 从私有 `.env.production` 重建到 `kt-template-online-api-env`,不得写入 Git。验收命令`pnpm exec jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand`、`pnpm run typecheck`、`pnpm run build`、`Test-Path .\dist\apps\napcat-webui-gateway\main.js`、`git diff --check`。安全边界:浏览器不得收到 WebUI token、Credential、上游 URL/端口、Docker 拓扑、Redis 地址或内部 secret。
NapCat WebUI Gateway 是独立运行的 NestJS 入口,生产镜像使用 `dockerfile.gateway` 打包 `dist/apps/napcat-webui-gateway/main.js` 并监听 `48086`。API 通过内部地址 `NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL=http://kt-napcat-webui-gateway:48086` 创建/续期/撤销 WebUI 会话Admin 浏览器只访问公开前缀 `NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL=/napcat-webui`。Gateway 运行时需要 `NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET`、`NAPCAT_WEBUI_GATEWAY_REDIS_HOST`、`NAPCAT_WEBUI_GATEWAY_REDIS_PORT`、`NAPCAT_WEBUI_GATEWAY_SESSION_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_TICKET_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_UPSTREAM_TIMEOUT_MS`;生产 secret 由 Jenkins 从私有 `.env.production` 重建到 `kt-template-online-api-env`,不得写入 Git。验收命令`pnpm exec jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand`、`pnpm run typecheck`、`pnpm run build`、`test -f dist/apps/napcat-webui-gateway/main.js`、`git diff --check`。安全边界:浏览器不得收到 WebUI token、Credential、上游 URL/端口、Docker 拓扑、Redis 地址或内部 secret。
## 启动
@ -208,9 +208,9 @@ pnpm test
BangDream 图片能力改动:
```powershell
.\scripts\bangdream-render-smoke.ps1 -OperationKey bangdream.song.search -Text "夏祭り" -OutFile ".kt-workspace/bangdream-smoke/song.jpg"
.\scripts\bangdream-render-smoke.ps1 -OperationKey bangdream.event.stage -Text "310" -OutFile ".kt-workspace/bangdream-smoke/stage.jpg" -ExpectedImageCount 5
```bash
bash scripts/bangdream-render-smoke.sh --operation-key bangdream.song.search --text "夏祭り" --out-file ".kt-workspace/bangdream-smoke/song.jpg"
bash scripts/bangdream-render-smoke.sh --operation-key bangdream.event.stage --text "310" --out-file ".kt-workspace/bangdream-smoke/stage.jpg" --expected-image-count 5
```
接口改动必须启动或复用本地服务,并真实调用一次对应接口。

View File

@ -7,12 +7,12 @@
3. Apply `sql/refactor-v3/01-seed-core.sql`.
4. Run `sql/refactor-v3/99-verify.sql`.
5. Start API against the dry-run database.
6. Run `scripts/refactor-v3/local-smoke.ps1`.
6. Run `bash scripts/refactor-v3/local-smoke.sh`.
## Online Backup
1. Confirm the exact API image tag and current database name.
2. Run `scripts/refactor-v3/db-backup-online.ps1`.
2. Run `bash scripts/refactor-v3/db-backup-online.sh --database <name> --output-directory <path>`.
3. Record backup path, timestamp, source database, and restore command.
## Online Rebuild

View File

@ -0,0 +1,341 @@
const fs = require('fs');
const path = require('path');
const { setTimeout: delay } = require('timers/promises');
const XLSX = require('xlsx');
const { loadImage } = require('skia-canvas');
const { createPlugin } = require('../src/modules/qqbot/plugins/bangdream/src');
const cases = [
{ name: 'song-search-detail', operationKey: 'bangdream.song.search', text: '136', expectedImageCount: 1 },
{ name: 'song-search-list', operationKey: 'bangdream.song.search', text: 'FIRE BIRD', expectedImageCount: 1 },
{ name: 'song-chart', operationKey: 'bangdream.song.chart', text: '136 expert', expectedImageCount: 1 },
{ name: 'song-random', operationKey: 'bangdream.song.random', text: 'FIRE BIRD', expectedImageCount: 1 },
{ name: 'song-meta', operationKey: 'bangdream.song.meta', text: 'cn', expectedImageCount: 1 },
{ name: 'card-search-detail', operationKey: 'bangdream.card.search', text: '472', expectedImageCount: 1 },
{ name: 'card-search-list', operationKey: 'bangdream.card.search', text: '香澄', expectedImageCount: 1 },
{ name: 'card-illustration', operationKey: 'bangdream.card.illustration', text: '472', expectedImageCountMin: 1 },
{ name: 'character-search-detail', operationKey: 'bangdream.character.search', text: '1', expectedImageCount: 1 },
{ name: 'character-search-list', operationKey: 'bangdream.character.search', text: '香澄', expectedImageCount: 1 },
{ name: 'event-search-detail', operationKey: 'bangdream.event.search', text: '50', expectedImageCount: 1 },
{ name: 'event-search-list', operationKey: 'bangdream.event.search', text: 'summer', expectedImageCount: 1 },
{ name: 'event-stage', operationKey: 'bangdream.event.stage', text: '310 cn -m', expectedImageCount: 5 },
{ name: 'player-search', operationKey: 'bangdream.player.search', text: '26591455 jp', expectedImageCount: 1, external: true },
{ name: 'gacha-search', operationKey: 'bangdream.gacha.search', text: '259', expectedImageCount: 1 },
{ name: 'gacha-simulate', operationKey: 'bangdream.gacha.simulate', text: '10 259', expectedImageCount: 1 },
{ name: 'cutoff-detail', operationKey: 'bangdream.cutoff.detail', text: 'ycx 1000 50 cn', expectedImageCount: 1 },
{ name: 'cutoff-detail-top10', operationKey: 'bangdream.cutoff.detail', text: 'ycx 10 100 cn', expectedImageCount: 1 },
{ name: 'cutoff-all', operationKey: 'bangdream.cutoff.all', text: '50 cn', expectedImageCount: 1 },
{ name: 'cutoff-recent', operationKey: 'bangdream.cutoff.recent', text: '1000 50 cn', expectedImageCount: 1 },
];
/**
* Reads the Bash-validated matrix payload from environment variables.
* @returns {{outDir: string, skipExternalPlayer: boolean}} Matrix payload.
*/
function readPayload() {
const outDir = process.env.BANGDREAM_MATRIX_OUT_DIR || '';
if (!outDir) {
throw new Error('BANGDREAM_MATRIX_OUT_DIR is required');
}
return {
outDir,
skipExternalPlayer:
process.env.BANGDREAM_MATRIX_SKIP_EXTERNAL_PLAYER === 'true',
};
}
/**
* Creates an HTTP-shaped error compatible with the BangDream adapters.
* @param {string} message Error message.
* @param {number} statusCode HTTP response status.
* @returns {Error & {response: {status: number}, statusCode: number}} Compatible error.
*/
function createHttpError(message, statusCode) {
const error = new Error(message);
error.statusCode = statusCode;
error.response = { status: statusCode };
return error;
}
/**
* Fetches one resource with an adapter-level timeout.
* @param {string | URL} url Resource URL.
* @param {{headers?: object, timeoutMs?: number}} [options] Request options.
* @returns {Promise<Response>} Fetch response.
*/
async function fetchWithTimeout(url, options = {}) {
const controller = new AbortController();
const timeoutMs = options.timeoutMs || 30000;
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
headers: options.headers,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
/**
* Reads and parses a JSON file synchronously.
* @param {string} filePath JSON file path.
* @returns {unknown} Parsed JSON value.
*/
function readJsonFile(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
/**
* Reads the first worksheet from an Excel file as row objects.
* @param {string} filePath Excel file path.
* @returns {object[]} Worksheet rows.
*/
function readExcelRows(filePath) {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
return XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]);
}
/**
* Requests a binary resource for the BangDream plugin.
* @param {string | URL} url Resource URL.
* @param {{headers?: object, timeoutMs?: number}} [options] Request options.
* @returns {Promise<{body: Buffer, statusCode: number}>} Binary response body and status.
*/
async function requestBuffer(url, options) {
const response = await fetchWithTimeout(url, options);
const body = Buffer.from(await response.arrayBuffer());
if (!response.ok) {
throw createHttpError(
`BangDream resource request failed: ${response.status}`,
response.status,
);
}
return { body, statusCode: response.status };
}
/**
* Requests and parses a JSON resource for the BangDream plugin.
* @param {string | URL} url Resource URL.
* @param {{headers?: object, timeoutMs?: number}} [options] Request options.
* @returns {Promise<{body: unknown, statusCode: number}>} Parsed response body and status.
*/
async function requestJson(url, options) {
const response = await fetchWithTimeout(url, options);
const responseText = await response.text();
if (!response.ok) {
throw createHttpError(
`BangDream JSON request failed: ${response.status}`,
response.status,
);
}
return { body: JSON.parse(responseText), statusCode: response.status };
}
/**
* Creates the filesystem and network adapter expected by the BangDream plugin.
* @returns {object} Plugin IO adapter.
*/
function createIoAdapter() {
return {
/** @param {string} key Environment key. @returns {string | undefined} Config value. */
getConfig(key) {
return process.env[key];
},
/** @param {string} filePath Asset path. @returns {Promise<Buffer>} Asset bytes. */
async readAssetFile(filePath) {
return fs.promises.readFile(filePath);
},
/** @param {string} filePath Excel path. @returns {Promise<object[]>} Worksheet rows. */
async readExcelRows(filePath) {
return readExcelRows(filePath);
},
/** @param {string} filePath JSON path. @returns {Promise<unknown>} Parsed value. */
async readJsonFile(filePath) {
return readJsonFile(filePath);
},
/** @param {string} filePath JSON path. @returns {unknown} Parsed value. */
readJsonFileSync(filePath) {
return readJsonFile(filePath);
},
requestArrayBuffer: requestBuffer,
requestJson,
/** @param {number} milliseconds Delay length. @returns {Promise<void>} Delay completion. */
async sleep(milliseconds) {
await delay(milliseconds);
},
/** @param {string} filePath Output path. @param {unknown} data JSON value. @returns {Promise<void>} Write completion. */
async writeJsonFile(filePath, data) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data));
},
};
}
/**
* Converts plugin errors to their stable human-readable representation.
* @param {unknown} error Error-like value.
* @returns {string} Normalized message.
*/
function normalizeError(error) {
return String(error?.message || error || 'BangDream command failed');
}
/**
* Converts a case name into a safe output filename component.
* @param {string} name Matrix case name.
* @returns {string} Filesystem-safe name.
*/
function safeName(name) {
return name.replace(/[^a-zA-Z0-9._-]+/g, '-');
}
/**
* Writes and decodes all images returned by one matrix case.
* @param {object} testCase Matrix case contract.
* @param {object} result Plugin operation result.
* @param {string} outDir Matrix output directory.
* @returns {Promise<object[]>} Image file metadata.
*/
async function writeCaseImages(testCase, result, outDir) {
const matches = [...result.replyText.matchAll(/base64:\/\/([A-Za-z0-9+/=]+)/g)];
if (matches.length === 0) {
throw new Error(`${testCase.name}: no CQ image payload`);
}
if (
testCase.expectedImageCount &&
matches.length !== testCase.expectedImageCount
) {
throw new Error(
`${testCase.name}: expected imageCount=${testCase.expectedImageCount}, got ${matches.length}`,
);
}
if (
testCase.expectedImageCountMin &&
matches.length < testCase.expectedImageCountMin
) {
throw new Error(
`${testCase.name}: expected imageCount>=${testCase.expectedImageCountMin}, got ${matches.length}`,
);
}
const files = [];
for (let index = 0; index < matches.length; index += 1) {
const output = path.join(
outDir,
`${safeName(testCase.name)}${index === 0 ? '' : `-${index + 1}`}.jpg`,
);
const buffer = Buffer.from(matches[index][1], 'base64');
fs.writeFileSync(output, buffer);
const image = await loadImage(output);
files.push({
bytes: buffer.length,
height: image.height,
out: output,
width: image.width,
});
}
return files;
}
/**
* Executes the full render matrix and writes its machine-readable summary.
* @returns {Promise<void>} Resolves after the summary has been written.
*/
async function main() {
const payload = readPayload();
fs.mkdirSync(payload.outDir, { recursive: true });
const manifest = readJsonFile(
path.join(process.cwd(), 'src/modules/qqbot/plugins/bangdream/plugin.json'),
);
const operations = manifest.operations.map((operation) => ({
handlerName: operation.handlerName,
key: operation.key,
}));
const plugin = createPlugin({
io: createIoAdapter(),
normalizeError,
operations,
});
await plugin.activate();
const summaries = [];
const executedKeys = new Set();
for (const testCase of cases) {
if (testCase.external && payload.skipExternalPlayer) {
summaries.push({
name: testCase.name,
operationKey: testCase.operationKey,
reason: 'external-player',
skipped: true,
});
continue;
}
const startedAt = Date.now();
const result = await plugin.executeOperation(testCase.operationKey, {
compress: true,
displayedServerList: '',
text: testCase.text,
});
const files = await writeCaseImages(testCase, result, payload.outDir);
executedKeys.add(testCase.operationKey);
summaries.push({
durationMs: Date.now() - startedAt,
files,
imageCount: result.imageCount,
name: testCase.name,
operationKey: testCase.operationKey,
query: result.query,
});
process.stdout.write(
`[matrix] ${testCase.name} imageCount=${result.imageCount} first=${files[0].width}x${files[0].height}\n`,
);
}
const manifestKeys = manifest.operations
.map((operation) => operation.key)
.sort();
const missingKeys = manifestKeys.filter(
(key) => !cases.some((item) => item.operationKey === key),
);
if (missingKeys.length > 0) {
throw new Error(`Smoke matrix missing operation keys: ${missingKeys.join(', ')}`);
}
const summary = {
caseCount: cases.length,
executedOperationKeys: [...executedKeys].sort(),
manifestOperationKeys: manifestKeys,
outDir: payload.outDir,
skipped: summaries.filter((item) => item.skipped),
summaries,
};
const summaryFile = path.join(payload.outDir, 'matrix-summary.json');
fs.writeFileSync(summaryFile, JSON.stringify(summary, null, 2));
process.stdout.write(
`${JSON.stringify(
{
caseCount: summary.caseCount,
executedOperationKeyCount: summary.executedOperationKeys.length,
skippedCount: summary.skipped.length,
summaryFile,
},
null,
2,
)}\n`,
);
}
/**
* Writes an uncaught matrix error to stderr and fails the process.
* @param {unknown} error Error-like value.
* @returns {void}
*/
function handleFatalError(error) {
process.stderr.write(`${String(error?.stack || error)}\n`);
process.exit(1);
}
main().catch(handleFatalError);

View File

@ -1,257 +0,0 @@
param(
[string]$OutDir = ".kt-workspace/bangdream-smoke/matrix",
[int]$TimeoutSeconds = 240,
[switch]$SkipExternalPlayer
)
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot
$ResolvedOutDir = if ([System.IO.Path]::IsPathRooted($OutDir)) {
$OutDir
} else {
Join-Path $ProjectRoot $OutDir
}
New-Item -ItemType Directory -Force -Path $ResolvedOutDir | Out-Null
$StdoutLog = Join-Path $ResolvedOutDir "matrix.out.log"
$StderrLog = Join-Path $ResolvedOutDir "matrix.err.log"
$SummaryFile = Join-Path $ResolvedOutDir "matrix-summary.json"
Remove-Item -LiteralPath $StdoutLog,$StderrLog,$SummaryFile -Force -ErrorAction SilentlyContinue
$Payload = @{
outDir = $ResolvedOutDir
skipExternalPlayer = [bool]$SkipExternalPlayer
}
$PayloadJson = $Payload | ConvertTo-Json -Compress -Depth 5
$PayloadBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($PayloadJson))
$NodeCode = @"
const fs = require("fs");
const path = require("path");
const XLSX = require("xlsx");
const { loadImage } = require("skia-canvas");
const payload = JSON.parse(Buffer.from("$PayloadBase64", "base64").toString("utf8"));
const { createPlugin } = require("./src/modules/qqbot/plugins/bangdream/src");
const cases = [
{ name: "song-search-detail", operationKey: "bangdream.song.search", text: "136", expectedImageCount: 1 },
{ name: "song-search-list", operationKey: "bangdream.song.search", text: "FIRE BIRD", expectedImageCount: 1 },
{ name: "song-chart", operationKey: "bangdream.song.chart", text: "136 expert", expectedImageCount: 1 },
{ name: "song-random", operationKey: "bangdream.song.random", text: "FIRE BIRD", expectedImageCount: 1 },
{ name: "song-meta", operationKey: "bangdream.song.meta", text: "cn", expectedImageCount: 1 },
{ name: "card-search-detail", operationKey: "bangdream.card.search", text: "472", expectedImageCount: 1 },
{ name: "card-search-list", operationKey: "bangdream.card.search", text: "香澄", expectedImageCount: 1 },
{ name: "card-illustration", operationKey: "bangdream.card.illustration", text: "472", expectedImageCountMin: 1 },
{ name: "character-search-detail", operationKey: "bangdream.character.search", text: "1", expectedImageCount: 1 },
{ name: "character-search-list", operationKey: "bangdream.character.search", text: "香澄", expectedImageCount: 1 },
{ name: "event-search-detail", operationKey: "bangdream.event.search", text: "50", expectedImageCount: 1 },
{ name: "event-search-list", operationKey: "bangdream.event.search", text: "summer", expectedImageCount: 1 },
{ name: "event-stage", operationKey: "bangdream.event.stage", text: "310 cn -m", expectedImageCount: 5 },
{ name: "player-search", operationKey: "bangdream.player.search", text: "26591455 jp", expectedImageCount: 1, external: true },
{ name: "gacha-search", operationKey: "bangdream.gacha.search", text: "259", expectedImageCount: 1 },
{ name: "gacha-simulate", operationKey: "bangdream.gacha.simulate", text: "10 259", expectedImageCount: 1 },
{ name: "cutoff-detail", operationKey: "bangdream.cutoff.detail", text: "ycx 1000 50 cn", expectedImageCount: 1 },
{ name: "cutoff-detail-top10", operationKey: "bangdream.cutoff.detail", text: "ycx 10 100 cn", expectedImageCount: 1 },
{ name: "cutoff-all", operationKey: "bangdream.cutoff.all", text: "50 cn", expectedImageCount: 1 },
{ name: "cutoff-recent", operationKey: "bangdream.cutoff.recent", text: "1000 50 cn", expectedImageCount: 1 },
];
function createHttpError(message, statusCode) {
const error = new Error(message);
error.statusCode = statusCode;
error.response = { status: statusCode };
return error;
}
async function fetchWithTimeout(url, options = {}) {
const controller = new AbortController();
const timeoutMs = options.timeoutMs || 30000;
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
headers: options.headers,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
function readJsonFile(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function readExcelRows(filePath) {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
return XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]);
}
async function requestBuffer(url, options) {
const response = await fetchWithTimeout(url, options);
const body = Buffer.from(await response.arrayBuffer());
if (!response.ok) {
throw createHttpError("BangDream resource request failed: " + response.status, response.status);
}
return { body, statusCode: response.status };
}
async function requestJson(url, options) {
const response = await fetchWithTimeout(url, options);
const text = await response.text();
if (!response.ok) {
throw createHttpError("BangDream JSON request failed: " + response.status, response.status);
}
return { body: JSON.parse(text), statusCode: response.status };
}
function safeName(name) {
return name.replace(/[^a-zA-Z0-9._-]+/g, "-");
}
async function writeCaseImages(testCase, result) {
const matches = [...result.replyText.matchAll(/base64:\/\/([A-Za-z0-9+/=]+)/g)];
if (matches.length === 0) throw new Error(testCase.name + ": no CQ image payload");
if (testCase.expectedImageCount && matches.length !== testCase.expectedImageCount) {
throw new Error(testCase.name + ": expected imageCount=" + testCase.expectedImageCount + ", got " + matches.length);
}
if (testCase.expectedImageCountMin && matches.length < testCase.expectedImageCountMin) {
throw new Error(testCase.name + ": expected imageCount>=" + testCase.expectedImageCountMin + ", got " + matches.length);
}
const files = [];
for (let index = 0; index < matches.length; index++) {
const output = path.join(payload.outDir, safeName(testCase.name) + (index === 0 ? "" : "-" + (index + 1)) + ".jpg");
const buffer = Buffer.from(matches[index][1], "base64");
fs.writeFileSync(output, buffer);
const image = await loadImage(output);
files.push({
bytes: buffer.length,
height: image.height,
out: output,
width: image.width,
});
}
return files;
}
(async () => {
fs.mkdirSync(payload.outDir, { recursive: true });
const manifest = readJsonFile(path.join(process.cwd(), "src/modules/qqbot/plugins/bangdream/plugin.json"));
const operations = manifest.operations.map((operation) => ({
handlerName: operation.handlerName,
key: operation.key,
}));
const plugin = createPlugin({
io: {
getConfig: (key) => process.env[key],
readAssetFile: async (filePath) => fs.promises.readFile(filePath),
readExcelRows: async (filePath) => readExcelRows(filePath),
readJsonFile: async (filePath) => readJsonFile(filePath),
readJsonFileSync: (filePath) => readJsonFile(filePath),
requestArrayBuffer: requestBuffer,
requestJson,
sleep: async (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
writeJsonFile: async (filePath, data) => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data));
},
},
normalizeError: (error) => String(error?.message || error || "BangDream command failed"),
operations,
});
await plugin.activate();
const summaries = [];
const executedKeys = new Set();
for (const testCase of cases) {
if (testCase.external && payload.skipExternalPlayer) {
summaries.push({
name: testCase.name,
operationKey: testCase.operationKey,
skipped: true,
reason: "external-player",
});
continue;
}
const startedAt = Date.now();
const result = await plugin.executeOperation(testCase.operationKey, {
compress: true,
displayedServerList: "",
text: testCase.text,
});
const files = await writeCaseImages(testCase, result);
executedKeys.add(testCase.operationKey);
summaries.push({
durationMs: Date.now() - startedAt,
files,
imageCount: result.imageCount,
name: testCase.name,
operationKey: testCase.operationKey,
query: result.query,
});
process.stdout.write("[matrix] " + testCase.name + " imageCount=" + result.imageCount + " first=" + files[0].width + "x" + files[0].height + "\n");
}
const manifestKeys = manifest.operations.map((operation) => operation.key).sort();
const missingKeys = manifestKeys.filter((key) => !cases.some((item) => item.operationKey === key));
if (missingKeys.length > 0) {
throw new Error("Smoke matrix missing operation keys: " + missingKeys.join(", "));
}
const summary = {
caseCount: cases.length,
executedOperationKeys: [...executedKeys].sort(),
manifestOperationKeys: manifestKeys,
outDir: payload.outDir,
skipped: summaries.filter((item) => item.skipped),
summaries,
};
const summaryFile = path.join(payload.outDir, "matrix-summary.json");
fs.writeFileSync(summaryFile, JSON.stringify(summary, null, 2));
process.stdout.write(JSON.stringify({
caseCount: summary.caseCount,
executedOperationKeyCount: summary.executedOperationKeys.length,
skippedCount: summary.skipped.length,
summaryFile,
}, null, 2) + "\n");
})().catch((error) => {
process.stderr.write(String(error?.stack || error) + "\n");
process.exit(1);
});
"@
$NodeCodeBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($NodeCode))
$EvalCode = "eval(Buffer.from('$NodeCodeBase64','base64').toString('utf8'))"
$env:TS_NODE_TRANSPILE_ONLY = "true"
$Process = Start-Process `
-FilePath "node" `
-ArgumentList @("-r", "ts-node/register", "-r", "tsconfig-paths/register", "-e", $EvalCode) `
-WorkingDirectory $ProjectRoot `
-RedirectStandardOutput $StdoutLog `
-RedirectStandardError $StderrLog `
-PassThru `
-WindowStyle Hidden
$Deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while (-not $Process.HasExited) {
if ((Get-Date) -ge $Deadline) {
Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue
Write-Output "BangDream smoke matrix timed out and process $($Process.Id) was killed."
if (Test-Path $StdoutLog) { Get-Content $StdoutLog }
if (Test-Path $StderrLog) { Get-Content $StderrLog }
exit 124
}
Start-Sleep -Milliseconds 500
$Process.Refresh()
}
if (Test-Path $StdoutLog) { Get-Content $StdoutLog }
if (Test-Path $StderrLog) { Get-Content $StderrLog }
if (-not (Test-Path $SummaryFile)) {
Write-Output "BangDream smoke matrix did not produce a summary file."
exit 1
}
exit $Process.ExitCode

View File

@ -0,0 +1,167 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
PROJECT_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd -P)
NODE_RUNNER="$SCRIPT_DIR/bangdream-render-smoke-matrix.cjs"
out_dir=".kt-workspace/bangdream-smoke/matrix"
timeout_seconds=240
skip_external_player=false
child_pid=""
# Prints command usage without performing render or network work.
print_help() {
cat <<'EOF'
Usage: bangdream-render-smoke-matrix.sh [options]
Run the bounded BangDream render matrix and write its JSON summary.
Options:
--out-dir PATH Matrix output directory
--timeout-seconds SECONDS Whole-process timeout (default: 240)
--skip-external-player Skip the external player lookup case
-h, --help Show this help
EOF
}
# Reports an invalid invocation and exits with the standard usage status.
usage_error() {
printf 'Error: %s\n\n' "$1" >&2
print_help >&2
exit 2
}
# Returns success when a value is an integer greater than zero.
is_positive_integer() {
[[ $1 =~ ^[1-9][0-9]*$ ]]
}
# Stops the matrix child started by this script and reaps it.
cleanup_child() {
if [[ -n ${child_pid:-} ]] && kill -0 "$child_pid" 2>/dev/null; then
kill -TERM "$child_pid" 2>/dev/null || true
for _ in {1..20}; do
if ! kill -0 "$child_pid" 2>/dev/null; then
break
fi
sleep 0.1
done
if kill -0 "$child_pid" 2>/dev/null; then
kill -KILL "$child_pid" 2>/dev/null || true
fi
wait "$child_pid" 2>/dev/null || true
fi
child_pid=""
}
# Converts an interrupt signal into a deterministic exit after cleanup.
# shellcheck disable=SC2317 # Invoked indirectly by trap.
handle_interrupt() {
cleanup_child
exit 130
}
# Converts a termination signal into a deterministic exit after cleanup.
# shellcheck disable=SC2317 # Invoked indirectly by trap.
handle_termination() {
cleanup_child
exit 143
}
# Replays captured output while preserving stdout and stderr separation.
print_logs() {
[[ -f $stdout_log ]] && cat -- "$stdout_log"
[[ -f $stderr_log ]] && cat -- "$stderr_log" >&2
}
while (($# > 0)); do
case "$1" in
--out-dir)
(($# >= 2)) || usage_error "--out-dir requires a value"
out_dir=$2
shift 2
;;
--timeout-seconds)
(($# >= 2)) || usage_error "--timeout-seconds requires a value"
timeout_seconds=$2
shift 2
;;
--skip-external-player)
skip_external_player=true
shift
;;
-h|--help)
print_help
exit 0
;;
--)
shift
(($# == 0)) || usage_error "unexpected positional argument: $1"
;;
*)
usage_error "unknown argument: $1"
;;
esac
done
[[ -n $out_dir ]] || usage_error "--out-dir cannot be empty"
is_positive_integer "$timeout_seconds" || usage_error "--timeout-seconds must be a positive integer"
if [[ $out_dir = /* ]]; then
resolved_out_dir=$out_dir
else
resolved_out_dir="$PROJECT_ROOT/$out_dir"
fi
mkdir -p -- "$resolved_out_dir"
stdout_log="$resolved_out_dir/matrix.out.log"
stderr_log="$resolved_out_dir/matrix.err.log"
summary_file="$resolved_out_dir/matrix-summary.json"
rm -f -- "$stdout_log" "$stderr_log" "$summary_file"
trap cleanup_child EXIT
trap handle_interrupt INT
trap handle_termination TERM
cd -- "$PROJECT_ROOT"
TS_NODE_TRANSPILE_ONLY=true \
BANGDREAM_MATRIX_OUT_DIR=$resolved_out_dir \
BANGDREAM_MATRIX_SKIP_EXTERNAL_PLAYER=$skip_external_player \
node -r ts-node/register -r tsconfig-paths/register "$NODE_RUNNER" \
>"$stdout_log" 2>"$stderr_log" &
child_pid=$!
deadline=$((SECONDS + timeout_seconds))
timed_out=false
while kill -0 "$child_pid" 2>/dev/null; do
if ((SECONDS >= deadline)); then
timed_out=true
cleanup_child
break
fi
sleep 0.5
done
if [[ $timed_out == true ]]; then
printf 'BangDream smoke matrix timed out after %s seconds and its process was killed.\n' "$timeout_seconds"
print_logs
exit 124
fi
process_status=0
if [[ -n $child_pid ]]; then
if wait "$child_pid"; then
process_status=0
else
process_status=$?
fi
child_pid=""
fi
print_logs
if [[ ! -f $summary_file ]]; then
printf 'BangDream smoke matrix did not produce a summary file.\n'
exit 1
fi
exit "$process_status"

View File

@ -0,0 +1,253 @@
const fs = require('fs');
const path = require('path');
const { setTimeout: delay } = require('timers/promises');
const XLSX = require('xlsx');
const { createPlugin } = require('../src/modules/qqbot/plugins/bangdream/src');
/**
* Reads the Bash-validated render payload from environment variables.
* @returns {{expectedImageCount: number, input: object, operationKey: string, outFile: string}} Render payload.
*/
function readPayload() {
const expectedImageCount = Number.parseInt(
process.env.BANGDREAM_EXPECTED_IMAGE_COUNT || '0',
10,
);
if (!Number.isInteger(expectedImageCount) || expectedImageCount < 0) {
throw new Error('BANGDREAM_EXPECTED_IMAGE_COUNT must be a non-negative integer');
}
const operationKey = process.env.BANGDREAM_OPERATION_KEY || '';
const outFile = process.env.BANGDREAM_OUT_FILE || '';
if (!operationKey || !outFile) {
throw new Error('BANGDREAM_OPERATION_KEY and BANGDREAM_OUT_FILE are required');
}
return {
expectedImageCount,
input: {
compress: true,
displayedServerList: process.env.BANGDREAM_DISPLAYED_SERVER_LIST || '',
text: process.env.BANGDREAM_TEXT || '',
useEasyBG: process.env.BANGDREAM_USE_EASY_BG === 'true',
},
operationKey,
outFile,
};
}
/**
* Creates an HTTP-shaped error compatible with the BangDream adapters.
* @param {string} message Error message.
* @param {number} statusCode HTTP response status.
* @returns {Error & {response: {status: number}, statusCode: number}} Compatible error.
*/
function createHttpError(message, statusCode) {
const error = new Error(message);
error.statusCode = statusCode;
error.response = { status: statusCode };
return error;
}
/**
* Fetches one resource with an adapter-level timeout.
* @param {string | URL} url Resource URL.
* @param {{headers?: object, timeoutMs?: number}} [options] Request options.
* @returns {Promise<Response>} Fetch response.
*/
async function fetchWithTimeout(url, options = {}) {
const controller = new AbortController();
const timeoutMs = options.timeoutMs || 30000;
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
headers: options.headers,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
/**
* Reads and parses a JSON file synchronously.
* @param {string} filePath JSON file path.
* @returns {unknown} Parsed JSON value.
*/
function readJsonFile(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
/**
* Reads the first worksheet from an Excel file as row objects.
* @param {string} filePath Excel file path.
* @returns {object[]} Worksheet rows.
*/
function readExcelRows(filePath) {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
return XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]);
}
/**
* Requests a binary resource for the BangDream plugin.
* @param {string | URL} url Resource URL.
* @param {{headers?: object, timeoutMs?: number}} [options] Request options.
* @returns {Promise<{body: Buffer, statusCode: number}>} Binary response body and status.
*/
async function requestBuffer(url, options) {
const response = await fetchWithTimeout(url, options);
const body = Buffer.from(await response.arrayBuffer());
if (!response.ok) {
throw createHttpError(
`BangDream resource request failed: ${response.status}`,
response.status,
);
}
return { body, statusCode: response.status };
}
/**
* Requests and parses a JSON resource for the BangDream plugin.
* @param {string | URL} url Resource URL.
* @param {{headers?: object, timeoutMs?: number}} [options] Request options.
* @returns {Promise<{body: unknown, statusCode: number}>} Parsed response body and status.
*/
async function requestJson(url, options) {
const response = await fetchWithTimeout(url, options);
const responseText = await response.text();
if (!response.ok) {
throw createHttpError(
`BangDream JSON request failed: ${response.status}`,
response.status,
);
}
return { body: JSON.parse(responseText), statusCode: response.status };
}
/**
* Creates the filesystem and network adapter expected by the BangDream plugin.
* @returns {object} Plugin IO adapter.
*/
function createIoAdapter() {
return {
/** @param {string} key Environment key. @returns {string | undefined} Config value. */
getConfig(key) {
return process.env[key];
},
/** @param {string} filePath Asset path. @returns {Promise<Buffer>} Asset bytes. */
async readAssetFile(filePath) {
return fs.promises.readFile(filePath);
},
/** @param {string} filePath Excel path. @returns {Promise<object[]>} Worksheet rows. */
async readExcelRows(filePath) {
return readExcelRows(filePath);
},
/** @param {string} filePath JSON path. @returns {Promise<unknown>} Parsed value. */
async readJsonFile(filePath) {
return readJsonFile(filePath);
},
/** @param {string} filePath JSON path. @returns {unknown} Parsed value. */
readJsonFileSync(filePath) {
return readJsonFile(filePath);
},
requestArrayBuffer: requestBuffer,
requestJson,
/** @param {number} milliseconds Delay length. @returns {Promise<void>} Delay completion. */
async sleep(milliseconds) {
await delay(milliseconds);
},
/** @param {string} filePath Output path. @param {unknown} data JSON value. @returns {Promise<void>} Write completion. */
async writeJsonFile(filePath, data) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data));
},
};
}
/**
* Converts plugin errors to their stable human-readable representation.
* @param {unknown} error Error-like value.
* @returns {string} Normalized message.
*/
function normalizeError(error) {
return String(error?.message || error || 'BangDream command failed');
}
/**
* Executes the requested operation and writes every CQ base64 image to disk.
* @returns {Promise<void>} Resolves after output metadata has been written to stdout.
*/
async function main() {
const payload = readPayload();
const manifest = readJsonFile(
path.join(process.cwd(), 'src/modules/qqbot/plugins/bangdream/plugin.json'),
);
const operations = manifest.operations.map((operation) => ({
handlerName: operation.handlerName,
key: operation.key,
}));
const plugin = createPlugin({
io: createIoAdapter(),
normalizeError,
operations,
});
await plugin.activate();
const result = await plugin.executeOperation(payload.operationKey, payload.input);
if (
payload.expectedImageCount > 0 &&
result.imageCount !== payload.expectedImageCount
) {
throw new Error(
`Expected imageCount=${payload.expectedImageCount}, got ${result.imageCount}`,
);
}
const matches = [...result.replyText.matchAll(/base64:\/\/([A-Za-z0-9+/=]+)/g)];
if (matches.length === 0) {
throw new Error('No image CQ payload');
}
fs.mkdirSync(path.dirname(payload.outFile), { recursive: true });
const parsedOutput = path.parse(payload.outFile);
const files = matches.map((match, index) => {
const outputFile =
index === 0
? payload.outFile
: path.join(
parsedOutput.dir,
`${parsedOutput.name}-${index + 1}${parsedOutput.ext || '.jpg'}`,
);
fs.writeFileSync(outputFile, Buffer.from(match[1], 'base64'));
return {
bytes: fs.statSync(outputFile).size,
out: outputFile,
};
});
process.stdout.write(
`${JSON.stringify(
{
bytes: files[0].bytes,
files,
imageCount: result.imageCount,
out: payload.outFile,
},
null,
2,
)}\n`,
);
process.exit(0);
}
/**
* Writes an uncaught render error to stderr and fails the process.
* @param {unknown} error Error-like value.
* @returns {void}
*/
function handleFatalError(error) {
process.stderr.write(`${String(error?.stack || error)}\n`);
process.exit(1);
}
main().catch(handleFatalError);

View File

@ -1,224 +0,0 @@
param(
[string]$OperationKey = "bangdream.event.search",
[string]$Text = "50",
[string]$DisplayedServerList = "cn jp",
[string]$OutFile = ".kt-workspace/bangdream-smoke/bangdream-smoke.jpg",
[int]$TimeoutSeconds = 45,
[int]$ExpectedImageCount = 0,
[switch]$UseEasyBg
)
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot
$ResolvedOutFile = if ([System.IO.Path]::IsPathRooted($OutFile)) {
$OutFile
} else {
Join-Path $ProjectRoot $OutFile
}
$OutDir = Split-Path -Parent $ResolvedOutFile
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
$LogDir = Join-Path $ProjectRoot ".kt-workspace/bangdream-smoke"
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
$LogName = [System.IO.Path]::GetFileNameWithoutExtension($ResolvedOutFile)
$StdoutLog = Join-Path $LogDir "$LogName.out.log"
$StderrLog = Join-Path $LogDir "$LogName.err.log"
Remove-Item -LiteralPath $StdoutLog,$StderrLog -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $ResolvedOutFile -Force -ErrorAction SilentlyContinue
$OutExtension = [System.IO.Path]::GetExtension($ResolvedOutFile)
Get-ChildItem -LiteralPath $OutDir -Filter "$LogName-*${OutExtension}" -ErrorAction SilentlyContinue |
Remove-Item -Force -ErrorAction SilentlyContinue
$Payload = @{
input = @{
compress = $true
displayedServerList = $DisplayedServerList
text = $Text
useEasyBG = [bool]$UseEasyBg
}
operationKey = $OperationKey
outFile = $ResolvedOutFile
expectedImageCount = $ExpectedImageCount
}
$PayloadJson = $Payload | ConvertTo-Json -Compress -Depth 5
$PayloadBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($PayloadJson))
$NodeCode = @"
const fs = require("fs");
const path = require("path");
const XLSX = require("xlsx");
const payload = JSON.parse(Buffer.from("$PayloadBase64", "base64").toString("utf8"));
const { createPlugin } = require("./src/modules/qqbot/plugins/bangdream/src");
function createHttpError(message, statusCode) {
const error = new Error(message);
error.statusCode = statusCode;
error.response = { status: statusCode };
return error;
}
async function fetchWithTimeout(url, options = {}) {
const controller = new AbortController();
const timeoutMs = options.timeoutMs || 30000;
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
headers: options.headers,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
function readJsonFile(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function readExcelRows(filePath) {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
return XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]);
}
async function requestBuffer(url, options) {
const response = await fetchWithTimeout(url, options);
const body = Buffer.from(await response.arrayBuffer());
if (!response.ok) {
throw createHttpError("BangDream resource request failed: " + response.status, response.status);
}
return {
body,
statusCode: response.status,
};
}
async function requestJson(url, options) {
const response = await fetchWithTimeout(url, options);
const text = await response.text();
if (!response.ok) {
throw createHttpError("BangDream JSON request failed: " + response.status, response.status);
}
return {
body: JSON.parse(text),
statusCode: response.status,
};
}
(async () => {
const manifest = readJsonFile(path.join(process.cwd(), "src/modules/qqbot/plugins/bangdream/plugin.json"));
const operations = manifest.operations.map((operation) => ({
handlerName: operation.handlerName,
key: operation.key,
}));
const plugin = createPlugin({
io: {
getConfig: (key) => process.env[key],
readAssetFile: async (filePath) => fs.promises.readFile(filePath),
readExcelRows: async (filePath) => readExcelRows(filePath),
readJsonFile: async (filePath) => readJsonFile(filePath),
readJsonFileSync: (filePath) => readJsonFile(filePath),
requestArrayBuffer: requestBuffer,
requestJson,
sleep: async (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
writeJsonFile: async (filePath, data) => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data));
},
},
normalizeError: (error) => String(error?.message || error || "BangDream command failed"),
operations,
});
await plugin.activate();
const result = await plugin.executeOperation(payload.operationKey, payload.input);
if (payload.expectedImageCount > 0 && result.imageCount !== payload.expectedImageCount) {
throw new Error(
"Expected imageCount=" + payload.expectedImageCount + ", got " + result.imageCount,
);
}
const matches = [...result.replyText.matchAll(/base64:\/\/([A-Za-z0-9+/=]+)/g)];
if (matches.length === 0) throw new Error("No image CQ payload");
fs.mkdirSync(path.dirname(payload.outFile), { recursive: true });
const parsed = path.parse(payload.outFile);
const files = matches.map((match, index) => {
const outFile =
index === 0
? payload.outFile
: path.join(
parsed.dir,
parsed.name + "-" + (index + 1) + (parsed.ext || ".jpg"),
);
fs.writeFileSync(outFile, Buffer.from(match[1], "base64"));
return {
bytes: fs.statSync(outFile).size,
out: outFile,
};
});
process.stdout.write(JSON.stringify({
bytes: files[0].bytes,
files,
imageCount: result.imageCount,
out: payload.outFile,
}, null, 2) + "\n");
process.exit(0);
})().catch((error) => {
process.stderr.write(String(error?.stack || error) + "\n");
process.exit(1);
});
"@
$NodeCodeBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($NodeCode))
$EvalCode = "eval(Buffer.from('$NodeCodeBase64','base64').toString('utf8'))"
$env:TS_NODE_TRANSPILE_ONLY = "true"
$Process = Start-Process `
-FilePath "node" `
-ArgumentList @("-r", "ts-node/register", "-r", "tsconfig-paths/register", "-e", $EvalCode) `
-WorkingDirectory $ProjectRoot `
-RedirectStandardOutput $StdoutLog `
-RedirectStandardError $StderrLog `
-PassThru `
-WindowStyle Hidden
function Test-SmokeCompleted {
if (-not (Test-Path -LiteralPath $ResolvedOutFile)) { return $false }
if ((Get-Item -LiteralPath $ResolvedOutFile).Length -le 0) { return $false }
if (-not (Test-Path -LiteralPath $StdoutLog)) { return $false }
return [bool](Select-String -Path $StdoutLog -Pattern '"bytes"' -Quiet)
}
$Deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$CompletedByOutput = $false
while (-not $Process.HasExited) {
if (Test-SmokeCompleted) {
$CompletedByOutput = $true
Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue
$Process.WaitForExit(5000) | Out-Null
break
}
if ((Get-Date) -ge $Deadline) {
break
}
Start-Sleep -Milliseconds 500
$Process.Refresh()
}
if ((-not $Process.HasExited) -and (-not $CompletedByOutput)) {
Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue
Write-Output "BangDream smoke timed out and process $($Process.Id) was killed."
if (Test-Path $StdoutLog) { Get-Content $StdoutLog }
if (Test-Path $StderrLog) { Get-Content $StderrLog }
exit 124
}
if (Test-Path $StdoutLog) { Get-Content $StdoutLog }
if (Test-Path $StderrLog) { Get-Content $StderrLog }
if ($CompletedByOutput) {
Write-Output "BangDream smoke output completed; lingering process $($Process.Id) was cleaned up."
exit 0
}
if (-not (Test-SmokeCompleted)) {
Write-Output "BangDream smoke did not produce an image file."
exit 1
}
exit $Process.ExitCode

237
scripts/bangdream-render-smoke.sh Executable file
View File

@ -0,0 +1,237 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
PROJECT_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd -P)
NODE_RUNNER="$SCRIPT_DIR/bangdream-render-smoke.cjs"
operation_key="bangdream.event.search"
text="50"
displayed_server_list="cn jp"
out_file=".kt-workspace/bangdream-smoke/bangdream-smoke.jpg"
timeout_seconds=45
expected_image_count=0
use_easy_bg=false
child_pid=""
# Prints command usage without performing render or network work.
print_help() {
cat <<'EOF'
Usage: bangdream-render-smoke.sh [options]
Run one bounded BangDream render smoke and write decoded image output.
Options:
--operation-key KEY Operation key (default: bangdream.event.search)
--text TEXT Operation input text (default: 50)
--displayed-server-list LIST
Displayed servers (default: "cn jp")
--out-file PATH Image output path
--timeout-seconds SECONDS Whole-process timeout (default: 45)
--expected-image-count N Require this imageCount when greater than zero
--use-easy-bg Enable the easy background renderer
-h, --help Show this help
EOF
}
# Reports an invalid invocation and exits with the standard usage status.
usage_error() {
printf 'Error: %s\n\n' "$1" >&2
print_help >&2
exit 2
}
# Returns success when a value is an integer greater than zero.
is_positive_integer() {
[[ $1 =~ ^[1-9][0-9]*$ ]]
}
# Returns success when a value is a non-negative integer.
is_non_negative_integer() {
[[ $1 =~ ^[0-9]+$ ]]
}
# Stops the render child started by this script and reaps it.
cleanup_child() {
if [[ -n ${child_pid:-} ]] && kill -0 "$child_pid" 2>/dev/null; then
kill -TERM "$child_pid" 2>/dev/null || true
for _ in {1..20}; do
if ! kill -0 "$child_pid" 2>/dev/null; then
break
fi
sleep 0.1
done
if kill -0 "$child_pid" 2>/dev/null; then
kill -KILL "$child_pid" 2>/dev/null || true
fi
wait "$child_pid" 2>/dev/null || true
fi
child_pid=""
}
# Converts an interrupt signal into a deterministic exit after cleanup.
# shellcheck disable=SC2317 # Invoked indirectly by trap.
handle_interrupt() {
cleanup_child
exit 130
}
# Converts a termination signal into a deterministic exit after cleanup.
# shellcheck disable=SC2317 # Invoked indirectly by trap.
handle_termination() {
cleanup_child
exit 143
}
# Returns success after the primary image and completion JSON are durable.
smoke_completed() {
[[ -s $resolved_out_file && -f $stdout_log ]] && grep -Fq '"bytes"' "$stdout_log"
}
# Replays captured output while preserving stdout and stderr separation.
print_logs() {
[[ -f $stdout_log ]] && cat -- "$stdout_log"
[[ -f $stderr_log ]] && cat -- "$stderr_log" >&2
}
while (($# > 0)); do
case "$1" in
--operation-key)
(($# >= 2)) || usage_error "--operation-key requires a value"
operation_key=$2
shift 2
;;
--text)
(($# >= 2)) || usage_error "--text requires a value"
text=$2
shift 2
;;
--displayed-server-list)
(($# >= 2)) || usage_error "--displayed-server-list requires a value"
displayed_server_list=$2
shift 2
;;
--out-file)
(($# >= 2)) || usage_error "--out-file requires a value"
out_file=$2
shift 2
;;
--timeout-seconds)
(($# >= 2)) || usage_error "--timeout-seconds requires a value"
timeout_seconds=$2
shift 2
;;
--expected-image-count)
(($# >= 2)) || usage_error "--expected-image-count requires a value"
expected_image_count=$2
shift 2
;;
--use-easy-bg)
use_easy_bg=true
shift
;;
-h|--help)
print_help
exit 0
;;
--)
shift
(($# == 0)) || usage_error "unexpected positional argument: $1"
;;
*)
usage_error "unknown argument: $1"
;;
esac
done
[[ -n $operation_key ]] || usage_error "--operation-key cannot be empty"
[[ -n $out_file ]] || usage_error "--out-file cannot be empty"
is_positive_integer "$timeout_seconds" || usage_error "--timeout-seconds must be a positive integer"
is_non_negative_integer "$expected_image_count" || usage_error "--expected-image-count must be a non-negative integer"
if [[ $out_file = /* ]]; then
resolved_out_file=$out_file
else
resolved_out_file="$PROJECT_ROOT/$out_file"
fi
out_dir=$(dirname -- "$resolved_out_file")
mkdir -p -- "$out_dir"
log_dir="$PROJECT_ROOT/.kt-workspace/bangdream-smoke"
mkdir -p -- "$log_dir"
output_filename=$(basename -- "$resolved_out_file")
log_name=${output_filename%.*}
if [[ $output_filename == *.* ]]; then
out_extension=.${output_filename##*.}
else
out_extension=""
fi
stdout_log="$log_dir/$log_name.out.log"
stderr_log="$log_dir/$log_name.err.log"
rm -f -- "$stdout_log" "$stderr_log" "$resolved_out_file"
shopt -s nullglob
generated_images=("$out_dir"/"$log_name"-*"$out_extension")
shopt -u nullglob
if ((${#generated_images[@]} > 0)); then
rm -f -- "${generated_images[@]}"
fi
trap cleanup_child EXIT
trap handle_interrupt INT
trap handle_termination TERM
cd -- "$PROJECT_ROOT"
TS_NODE_TRANSPILE_ONLY=true \
BANGDREAM_OPERATION_KEY=$operation_key \
BANGDREAM_TEXT=$text \
BANGDREAM_DISPLAYED_SERVER_LIST=$displayed_server_list \
BANGDREAM_OUT_FILE=$resolved_out_file \
BANGDREAM_EXPECTED_IMAGE_COUNT=$expected_image_count \
BANGDREAM_USE_EASY_BG=$use_easy_bg \
node -r ts-node/register -r tsconfig-paths/register "$NODE_RUNNER" \
>"$stdout_log" 2>"$stderr_log" &
child_pid=$!
deadline=$((SECONDS + timeout_seconds))
completed_by_output=false
timed_out=false
while kill -0 "$child_pid" 2>/dev/null; do
if smoke_completed; then
completed_by_output=true
cleanup_child
break
fi
if ((SECONDS >= deadline)); then
timed_out=true
cleanup_child
break
fi
sleep 0.5
done
if [[ $timed_out == true ]]; then
printf 'BangDream smoke timed out after %s seconds and its process was killed.\n' "$timeout_seconds"
print_logs
exit 124
fi
process_status=0
if [[ -n $child_pid ]]; then
if wait "$child_pid"; then
process_status=0
else
process_status=$?
fi
child_pid=""
fi
print_logs
if [[ $completed_by_output == true ]]; then
printf 'BangDream smoke output completed; its lingering process was cleaned up.\n'
exit 0
fi
if ! smoke_completed; then
printf 'BangDream smoke did not produce an image file.\n'
exit 1
fi
exit "$process_status"

View File

@ -1,14 +0,0 @@
param(
[Parameter(Mandatory = $true)][string]$Database,
[Parameter(Mandatory = $true)][string]$OutputDirectory
)
$ErrorActionPreference = "Stop"
if ($Database -notmatch '^[A-Za-z0-9_]+$') {
throw "Database must match ^[A-Za-z0-9_]+$"
}
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
$target = Join-Path $OutputDirectory "$Database-refactor-v3-$stamp.sql"
mysqldump --set-gtid-purged=OFF --single-transaction --routines --triggers --default-character-set=utf8mb4 "--result-file=$target" $Database
Write-Output $target

View File

@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -Eeuo pipefail
database=""
output_directory=""
timeout_seconds=300
# Prints command usage without accessing the database.
print_help() {
cat <<'EOF'
Usage: db-backup-online.sh --database NAME --output-directory PATH [options]
Create a bounded logical MySQL backup and print its absolute output path.
MySQL credentials are resolved by the mysql client and are never printed.
Required:
--database NAME Database name (letters, digits, and underscores)
--output-directory PATH Directory for the timestamped SQL backup
Options:
--timeout-seconds SECONDS mysqldump timeout (default: 300)
-h, --help Show this help
EOF
}
# Reports an invalid invocation and exits with the standard usage status.
usage_error() {
printf 'Error: %s\n\n' "$1" >&2
print_help >&2
exit 2
}
# Returns success when a value is an integer greater than zero.
is_positive_integer() {
[[ $1 =~ ^[1-9][0-9]*$ ]]
}
while (($# > 0)); do
case "$1" in
--database)
(($# >= 2)) || usage_error "--database requires a value"
database=$2
shift 2
;;
--output-directory)
(($# >= 2)) || usage_error "--output-directory requires a value"
output_directory=$2
shift 2
;;
--timeout-seconds)
(($# >= 2)) || usage_error "--timeout-seconds requires a value"
timeout_seconds=$2
shift 2
;;
-h|--help)
print_help
exit 0
;;
--)
shift
(($# == 0)) || usage_error "unexpected positional argument: $1"
;;
*)
usage_error "unknown argument: $1"
;;
esac
done
[[ $database =~ ^[A-Za-z0-9_]+$ ]] || usage_error "--database must match ^[A-Za-z0-9_]+$"
[[ -n $output_directory ]] || usage_error "--output-directory is required"
is_positive_integer "$timeout_seconds" || usage_error "--timeout-seconds must be a positive integer"
command -v timeout >/dev/null 2>&1 || usage_error "GNU timeout is required"
command -v mysqldump >/dev/null 2>&1 || usage_error "mysqldump is required"
mkdir -p -- "$output_directory"
output_directory=$(cd -- "$output_directory" && pwd -P)
stamp=$(date +%Y%m%d-%H%M%S)
target="$output_directory/$database-refactor-v3-$stamp.sql"
timeout --foreground --kill-after=5s "${timeout_seconds}s" \
mysqldump \
--set-gtid-purged=OFF \
--single-transaction \
--routines \
--triggers \
--default-character-set=utf8mb4 \
"--result-file=$target" \
"$database"
printf '%s\n' "$target"

View File

@ -1,41 +0,0 @@
param(
[Parameter(Mandatory = $true)][string]$Database,
[string]$HostName = "127.0.0.1",
[int]$Port = 3306,
[string]$User = "root"
)
$ErrorActionPreference = "Stop"
if ($Database -notmatch '^[A-Za-z0-9_]+$') {
throw "Database must match ^[A-Za-z0-9_]+$"
}
$root = Resolve-Path (Join-Path $PSScriptRoot "..\..")
$schema = Join-Path $root "sql\refactor-v3\00-full-schema.sql"
$seed = Join-Path $root "sql\refactor-v3\01-seed-core.sql"
$verify = Join-Path $root "sql\refactor-v3\99-verify.sql"
function Invoke-Mysql {
param(
[Parameter(Mandatory = $true)][string[]]$Arguments
)
mysql --default-character-set=utf8mb4 @Arguments
if ($LASTEXITCODE -ne 0) {
throw "mysql failed with exit code $LASTEXITCODE"
}
}
function Invoke-MysqlSource {
param(
[Parameter(Mandatory = $true)][string]$Path
)
$sourcePath = (Resolve-Path -LiteralPath $Path).Path.Replace("\", "/")
Invoke-Mysql -Arguments @("-h", $HostName, "-P", "$Port", "-u", $User, $Database, "--execute=source $sourcePath")
}
Invoke-Mysql -Arguments @("-h", $HostName, "-P", "$Port", "-u", $User, "-e", "DROP DATABASE IF EXISTS ``$Database``; CREATE DATABASE ``$Database`` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;")
Invoke-MysqlSource -Path $schema
Invoke-MysqlSource -Path $seed
Invoke-MysqlSource -Path $verify

150
scripts/refactor-v3/db-dry-run.sh Executable file
View File

@ -0,0 +1,150 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
PROJECT_ROOT=$(cd -- "$SCRIPT_DIR/../.." && pwd -P)
database=""
host_name="127.0.0.1"
port=3306
user_name="root"
timeout_seconds=120
execute=false
# Prints command usage without accessing the database.
print_help() {
cat <<'EOF'
Usage: db-dry-run.sh --database NAME [options]
Plan a disposable refactor-v3 database rebuild. The default is plan-only;
pass --execute explicitly to drop/recreate the database and load SQL files.
Required:
--database NAME Disposable target database
Options:
--host HOST MySQL host (default: 127.0.0.1)
--port PORT MySQL port (default: 3306)
--user USER MySQL user (default: root)
--timeout-seconds SECONDS Timeout for each mysql command (default: 120)
--execute Apply the destructive rebuild
-h, --help Show this help
EOF
}
# Reports an invalid invocation and exits with the standard usage status.
usage_error() {
printf 'Error: %s\n\n' "$1" >&2
print_help >&2
exit 2
}
# Returns success when a value is an integer greater than zero.
is_positive_integer() {
[[ $1 =~ ^[1-9][0-9]*$ ]]
}
# Prints the destructive scope and rollback/verification intent before writes.
print_plan() {
printf 'mode=%s\n' "$([[ $execute == true ]] && printf execute || printf plan-only)"
printf 'source.schema=%s\n' "$schema_file"
printf 'source.seed=%s\n' "$seed_file"
printf 'target=mysql://%s:%s/%s (user=%s)\n' "$host_name" "$port" "$database" "$user_name"
printf 'rollback.backup=not-created (disposable dry-run target; recreate or restore separately)\n'
printf 'verification.intent=execute %s after schema and seed load\n' "$verify_file"
}
# Runs one bounded mysql command without exposing credentials.
run_mysql_command() {
timeout --foreground --kill-after=5s "${timeout_seconds}s" \
"${mysql_base[@]}" "$@"
}
# Loads one SQL file into the target database with a bounded mysql process.
run_mysql_file() {
local sql_file=$1
timeout --foreground --kill-after=5s "${timeout_seconds}s" \
"${mysql_base[@]}" "$database" <"$sql_file"
}
while (($# > 0)); do
case "$1" in
--database)
(($# >= 2)) || usage_error "--database requires a value"
database=$2
shift 2
;;
--host)
(($# >= 2)) || usage_error "--host requires a value"
host_name=$2
shift 2
;;
--port)
(($# >= 2)) || usage_error "--port requires a value"
port=$2
shift 2
;;
--user)
(($# >= 2)) || usage_error "--user requires a value"
user_name=$2
shift 2
;;
--timeout-seconds)
(($# >= 2)) || usage_error "--timeout-seconds requires a value"
timeout_seconds=$2
shift 2
;;
--execute)
execute=true
shift
;;
-h|--help)
print_help
exit 0
;;
--)
shift
(($# == 0)) || usage_error "unexpected positional argument: $1"
;;
*)
usage_error "unknown argument: $1"
;;
esac
done
[[ $database =~ ^[A-Za-z0-9_]+$ ]] || usage_error "--database must match ^[A-Za-z0-9_]+$"
[[ -n $host_name ]] || usage_error "--host cannot be empty"
[[ -n $user_name ]] || usage_error "--user cannot be empty"
if ! is_positive_integer "$port" || ((port > 65535)); then
usage_error "--port must be between 1 and 65535"
fi
is_positive_integer "$timeout_seconds" || usage_error "--timeout-seconds must be a positive integer"
schema_file="$PROJECT_ROOT/sql/refactor-v3/00-full-schema.sql"
seed_file="$PROJECT_ROOT/sql/refactor-v3/01-seed-core.sql"
verify_file="$PROJECT_ROOT/sql/refactor-v3/99-verify.sql"
[[ -f $schema_file ]] || usage_error "schema file does not exist: $schema_file"
[[ -f $seed_file ]] || usage_error "seed file does not exist: $seed_file"
[[ -f $verify_file ]] || usage_error "verification file does not exist: $verify_file"
print_plan
if [[ $execute != true ]]; then
printf 'execution=skipped (pass --execute to apply)\n'
exit 0
fi
command -v timeout >/dev/null 2>&1 || usage_error "GNU timeout is required"
command -v mysql >/dev/null 2>&1 || usage_error "mysql is required"
mysql_base=(
mysql
--default-character-set=utf8mb4
-h "$host_name"
-P "$port"
-u "$user_name"
)
create_sql="DROP DATABASE IF EXISTS \`$database\`; CREATE DATABASE \`$database\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
run_mysql_command --execute="$create_sql"
run_mysql_file "$schema_file"
run_mysql_file "$seed_file"
run_mysql_file "$verify_file"

View File

@ -1,16 +0,0 @@
param(
[Parameter(Mandatory = $true)][string]$Database,
[Parameter(Mandatory = $true)][string]$BackupFile
)
$ErrorActionPreference = "Stop"
if ($Database -notmatch '^[A-Za-z0-9_]+$') {
throw "Database must match ^[A-Za-z0-9_]+$"
}
if (-not (Test-Path -LiteralPath $BackupFile -PathType Leaf)) {
throw "BackupFile does not exist"
}
$sourcePath = (Resolve-Path -LiteralPath $BackupFile).Path.Replace("\", "/")
mysql -e "DROP DATABASE IF EXISTS ``$Database``; CREATE DATABASE ``$Database`` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
mysql --default-character-set=utf8mb4 $Database --execute="source $sourcePath"

View File

@ -0,0 +1,114 @@
#!/usr/bin/env bash
set -Eeuo pipefail
database=""
backup_file=""
timeout_seconds=300
execute=false
# Prints command usage without accessing the database.
print_help() {
cat <<'EOF'
Usage: db-restore-online.sh --database NAME --backup-file PATH [options]
Plan an online database restore. The default is plan-only; pass --execute
explicitly to drop/recreate the database and load the selected backup.
MySQL connection and credentials come from mysql client defaults.
Required:
--database NAME Target database
--backup-file PATH Existing SQL backup to restore
Options:
--timeout-seconds SECONDS Timeout for each mysql command (default: 300)
--execute Apply the destructive restore
-h, --help Show this help
EOF
}
# Reports an invalid invocation and exits with the standard usage status.
usage_error() {
printf 'Error: %s\n\n' "$1" >&2
print_help >&2
exit 2
}
# Returns success when a value is an integer greater than zero.
is_positive_integer() {
[[ $1 =~ ^[1-9][0-9]*$ ]]
}
# Prints the destructive scope and rollback/verification intent before writes.
print_plan() {
printf 'mode=%s\n' "$([[ $execute == true ]] && printf execute || printf plan-only)"
printf 'source.backup=%s\n' "$resolved_backup_file"
printf 'target.database=%s (connection=mysql client defaults)\n' "$database"
printf 'rollback.backup=%s\n' "$resolved_backup_file"
printf 'verification.intent=confirm restore command success, then run the project post-restore verification queries\n'
}
# Runs one bounded mysql command without exposing credentials.
run_mysql_command() {
timeout --foreground --kill-after=5s "${timeout_seconds}s" mysql "$@"
}
# Loads the selected backup with a bounded mysql process.
run_mysql_restore() {
timeout --foreground --kill-after=5s "${timeout_seconds}s" \
mysql --default-character-set=utf8mb4 "$database" <"$resolved_backup_file"
}
while (($# > 0)); do
case "$1" in
--database)
(($# >= 2)) || usage_error "--database requires a value"
database=$2
shift 2
;;
--backup-file)
(($# >= 2)) || usage_error "--backup-file requires a value"
backup_file=$2
shift 2
;;
--timeout-seconds)
(($# >= 2)) || usage_error "--timeout-seconds requires a value"
timeout_seconds=$2
shift 2
;;
--execute)
execute=true
shift
;;
-h|--help)
print_help
exit 0
;;
--)
shift
(($# == 0)) || usage_error "unexpected positional argument: $1"
;;
*)
usage_error "unknown argument: $1"
;;
esac
done
[[ $database =~ ^[A-Za-z0-9_]+$ ]] || usage_error "--database must match ^[A-Za-z0-9_]+$"
[[ -n $backup_file ]] || usage_error "--backup-file is required"
is_positive_integer "$timeout_seconds" || usage_error "--timeout-seconds must be a positive integer"
[[ -f $backup_file ]] || usage_error "backup file does not exist: $backup_file"
backup_directory=$(cd -- "$(dirname -- "$backup_file")" && pwd -P)
resolved_backup_file="$backup_directory/$(basename -- "$backup_file")"
print_plan
if [[ $execute != true ]]; then
printf 'execution=skipped (pass --execute to apply)\n'
exit 0
fi
command -v timeout >/dev/null 2>&1 || usage_error "GNU timeout is required"
command -v mysql >/dev/null 2>&1 || usage_error "mysql is required"
create_sql="DROP DATABASE IF EXISTS \`$database\`; CREATE DATABASE \`$database\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
run_mysql_command --execute="$create_sql"
run_mysql_restore

View File

@ -1,10 +0,0 @@
param(
[string]$BaseUrl = "http://127.0.0.1:48085"
)
$ErrorActionPreference = "Stop"
$runtime = Invoke-RestMethod -Method Get -Uri "$BaseUrl/health/runtime" -TimeoutSec 10
if (-not $runtime.service) {
throw "Runtime health response did not include service"
}
Write-Output "runtime.service=$($runtime.service)"

View File

@ -0,0 +1,82 @@
#!/usr/bin/env bash
set -Eeuo pipefail
base_url="http://127.0.0.1:48085"
timeout_seconds=10
# Prints command usage without performing an HTTP request.
print_help() {
cat <<'EOF'
Usage: local-smoke.sh [options]
Read the bounded local runtime health endpoint and print runtime.service.
Options:
--base-url URL API base URL (default: http://127.0.0.1:48085)
--timeout-seconds SECONDS Timeout for curl and jq (default: 10)
-h, --help Show this help
EOF
}
# Reports an invalid invocation and exits with the standard usage status.
usage_error() {
printf 'Error: %s\n\n' "$1" >&2
print_help >&2
exit 2
}
# Returns success when a value is an integer greater than zero.
is_positive_integer() {
[[ $1 =~ ^[1-9][0-9]*$ ]]
}
while (($# > 0)); do
case "$1" in
--base-url)
(($# >= 2)) || usage_error "--base-url requires a value"
base_url=$2
shift 2
;;
--timeout-seconds)
(($# >= 2)) || usage_error "--timeout-seconds requires a value"
timeout_seconds=$2
shift 2
;;
-h|--help)
print_help
exit 0
;;
--)
shift
(($# == 0)) || usage_error "unexpected positional argument: $1"
;;
*)
usage_error "unknown argument: $1"
;;
esac
done
[[ -n $base_url ]] || usage_error "--base-url cannot be empty"
is_positive_integer "$timeout_seconds" || usage_error "--timeout-seconds must be a positive integer"
command -v timeout >/dev/null 2>&1 || usage_error "GNU timeout is required"
command -v curl >/dev/null 2>&1 || usage_error "curl is required"
command -v jq >/dev/null 2>&1 || usage_error "jq is required"
base_url=${base_url%/}
runtime_json=$(
timeout --foreground --kill-after=2s "${timeout_seconds}s" \
curl \
--fail \
--silent \
--show-error \
--connect-timeout "$timeout_seconds" \
--max-time "$timeout_seconds" \
"$base_url/health/runtime"
)
service=$(
printf '%s\n' "$runtime_json" |
timeout --foreground --kill-after=2s "${timeout_seconds}s" \
jq -er '.service | select(. != null) | tostring | select(length > 0)'
)
printf 'runtime.service=%s\n' "$service"

View File

@ -161,20 +161,21 @@ describe('refactor v3 schema skeleton', () => {
expect(seed).toContain('INSERT IGNORE INTO admin_role_menu');
});
it('runs dry-run SQL sources with utf8mb4 and fails on native mysql errors', () => {
it('runs dry-run SQL sources with utf8mb4 and bounded native mysql errors', () => {
const dryRunScript = readFileSync(
join(root, 'scripts/refactor-v3/db-dry-run.ps1'),
join(root, 'scripts/refactor-v3/db-dry-run.sh'),
'utf8',
);
expect(dryRunScript).toContain('set -Eeuo pipefail');
expect(dryRunScript).toContain('--default-character-set=utf8mb4');
expect(dryRunScript).toContain('$LASTEXITCODE');
expect(dryRunScript).toContain('throw');
expect(dryRunScript).toContain('timeout --foreground');
expect(dryRunScript).toContain('if [[ $execute != true ]]');
});
it('uses the Nest listen port as the local smoke default base URL', () => {
const localSmokeScript = readFileSync(
join(root, 'scripts/refactor-v3/local-smoke.ps1'),
join(root, 'scripts/refactor-v3/local-smoke.sh'),
'utf8',
);
const main = readFileSync(join(root, 'src/main.ts'), 'utf8');