diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfdb8b7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/API.md b/API.md index 2ecf13d..f338a48 100644 --- a/API.md +++ b/API.md @@ -387,12 +387,12 @@ NapCat Chinese Desktop Runtime 使用 KT `NapCatQQ` fork 源码构建出的 `Nap API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,且 `fork-artifact.json` 必须包含完整 marker metadata:upstream 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;推送成功不等于发布完成。 diff --git a/README.md b/README.md index 26ee182..21038e3 100644 --- a/README.md +++ b/README.md @@ -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 ``` 接口改动必须启动或复用本地服务,并真实调用一次对应接口。 diff --git a/docs/refactor-v3/rebuild-runbook.md b/docs/refactor-v3/rebuild-runbook.md index 600347d..e59ab0f 100644 --- a/docs/refactor-v3/rebuild-runbook.md +++ b/docs/refactor-v3/rebuild-runbook.md @@ -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 --output-directory `. 3. Record backup path, timestamp, source database, and restore command. ## Online Rebuild diff --git a/scripts/bangdream-render-smoke-matrix.cjs b/scripts/bangdream-render-smoke-matrix.cjs new file mode 100644 index 0000000..3625cc0 --- /dev/null +++ b/scripts/bangdream-render-smoke-matrix.cjs @@ -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} 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} Asset bytes. */ + async readAssetFile(filePath) { + return fs.promises.readFile(filePath); + }, + /** @param {string} filePath Excel path. @returns {Promise} Worksheet rows. */ + async readExcelRows(filePath) { + return readExcelRows(filePath); + }, + /** @param {string} filePath JSON path. @returns {Promise} 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} Delay completion. */ + async sleep(milliseconds) { + await delay(milliseconds); + }, + /** @param {string} filePath Output path. @param {unknown} data JSON value. @returns {Promise} 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} 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} 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); diff --git a/scripts/bangdream-render-smoke-matrix.ps1 b/scripts/bangdream-render-smoke-matrix.ps1 deleted file mode 100644 index 05e3257..0000000 --- a/scripts/bangdream-render-smoke-matrix.ps1 +++ /dev/null @@ -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 diff --git a/scripts/bangdream-render-smoke-matrix.sh b/scripts/bangdream-render-smoke-matrix.sh new file mode 100755 index 0000000..4c6c538 --- /dev/null +++ b/scripts/bangdream-render-smoke-matrix.sh @@ -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" diff --git a/scripts/bangdream-render-smoke.cjs b/scripts/bangdream-render-smoke.cjs new file mode 100644 index 0000000..7e57624 --- /dev/null +++ b/scripts/bangdream-render-smoke.cjs @@ -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} 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} Asset bytes. */ + async readAssetFile(filePath) { + return fs.promises.readFile(filePath); + }, + /** @param {string} filePath Excel path. @returns {Promise} Worksheet rows. */ + async readExcelRows(filePath) { + return readExcelRows(filePath); + }, + /** @param {string} filePath JSON path. @returns {Promise} 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} Delay completion. */ + async sleep(milliseconds) { + await delay(milliseconds); + }, + /** @param {string} filePath Output path. @param {unknown} data JSON value. @returns {Promise} 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} 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); diff --git a/scripts/bangdream-render-smoke.ps1 b/scripts/bangdream-render-smoke.ps1 deleted file mode 100644 index 5bcc2e8..0000000 --- a/scripts/bangdream-render-smoke.ps1 +++ /dev/null @@ -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 diff --git a/scripts/bangdream-render-smoke.sh b/scripts/bangdream-render-smoke.sh new file mode 100755 index 0000000..6a0c5c5 --- /dev/null +++ b/scripts/bangdream-render-smoke.sh @@ -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" diff --git a/scripts/refactor-v3/db-backup-online.ps1 b/scripts/refactor-v3/db-backup-online.ps1 deleted file mode 100644 index bfd748a..0000000 --- a/scripts/refactor-v3/db-backup-online.ps1 +++ /dev/null @@ -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 diff --git a/scripts/refactor-v3/db-backup-online.sh b/scripts/refactor-v3/db-backup-online.sh new file mode 100755 index 0000000..c128d57 --- /dev/null +++ b/scripts/refactor-v3/db-backup-online.sh @@ -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" diff --git a/scripts/refactor-v3/db-dry-run.ps1 b/scripts/refactor-v3/db-dry-run.ps1 deleted file mode 100644 index 2418875..0000000 --- a/scripts/refactor-v3/db-dry-run.ps1 +++ /dev/null @@ -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 diff --git a/scripts/refactor-v3/db-dry-run.sh b/scripts/refactor-v3/db-dry-run.sh new file mode 100755 index 0000000..c2da26a --- /dev/null +++ b/scripts/refactor-v3/db-dry-run.sh @@ -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" diff --git a/scripts/refactor-v3/db-restore-online.ps1 b/scripts/refactor-v3/db-restore-online.ps1 deleted file mode 100644 index 1e2aaf6..0000000 --- a/scripts/refactor-v3/db-restore-online.ps1 +++ /dev/null @@ -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" diff --git a/scripts/refactor-v3/db-restore-online.sh b/scripts/refactor-v3/db-restore-online.sh new file mode 100755 index 0000000..6ebcc40 --- /dev/null +++ b/scripts/refactor-v3/db-restore-online.sh @@ -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 diff --git a/scripts/refactor-v3/local-smoke.ps1 b/scripts/refactor-v3/local-smoke.ps1 deleted file mode 100644 index c045d0f..0000000 --- a/scripts/refactor-v3/local-smoke.ps1 +++ /dev/null @@ -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)" diff --git a/scripts/refactor-v3/local-smoke.sh b/scripts/refactor-v3/local-smoke.sh new file mode 100755 index 0000000..5d45b76 --- /dev/null +++ b/scripts/refactor-v3/local-smoke.sh @@ -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" diff --git a/test/refactor-v3/schema-map.spec.ts b/test/refactor-v3/schema-map.spec.ts index d6f2dc2..8310b6f 100644 --- a/test/refactor-v3/schema-map.spec.ts +++ b/test/refactor-v3/schema-map.spec.ts @@ -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');