refactor: purge Node.js, Rust is the one true backend

- Moved boosapi-rs/* to repo root
- Deleted all Node.js source (src/server/, src/web/, scripts/)
- Deleted node_modules, package.json, tsconfig, vite.config
- Updated .gitignore and AGENTS.md for Rust-only stack
- Fixed static file path for frontend dist/web/
- Dockerfile stays the same (already Rust-only)
- Kept dist/web/ for Rust static serving
This commit is contained in:
boos
2026-05-18 09:11:29 +08:00
parent 725f44699a
commit 5d29f03d50
1165 changed files with 271 additions and 323571 deletions
-250
View File
@@ -1,250 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
const mode = process.argv.includes('--write') ? 'write' : 'dry-run';
const root = process.cwd();
const targetDir = path.join(root, 'src', 'server');
const METHOD_NAMES = new Set(['all', 'get', 'run']);
const FUNCTION_KINDS = new Set([
ts.SyntaxKind.FunctionDeclaration,
ts.SyntaxKind.FunctionExpression,
ts.SyntaxKind.ArrowFunction,
ts.SyntaxKind.MethodDeclaration,
ts.SyntaxKind.GetAccessor,
ts.SyntaxKind.SetAccessor,
]);
function listTsFiles(dir) {
const out = [];
const stack = [dir];
while (stack.length > 0) {
const current = stack.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
continue;
}
if (entry.isFile() && full.endsWith('.ts')) {
out.push(full);
}
}
}
return out;
}
function isFunctionLike(node) {
return FUNCTION_KINDS.has(node.kind);
}
function hasAsyncModifier(node) {
return !!node.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.AsyncKeyword);
}
function getFunctionTokenPos(node, sourceFile) {
if (node.kind === ts.SyntaxKind.ArrowFunction) {
return node.getStart(sourceFile);
}
if (node.kind === ts.SyntaxKind.MethodDeclaration || node.kind === ts.SyntaxKind.GetAccessor || node.kind === ts.SyntaxKind.SetAccessor) {
if (!node.name) return node.getStart(sourceFile);
return node.name.getStart(sourceFile);
}
let pos = node.getStart(sourceFile);
const children = node.getChildren(sourceFile);
for (const child of children) {
if (child.kind === ts.SyntaxKind.FunctionKeyword) {
pos = child.getStart(sourceFile);
break;
}
}
return pos;
}
function findNearestFunction(node) {
let current = node.parent;
while (current) {
if (isFunctionLike(current)) return current;
current = current.parent;
}
return null;
}
function unwrapExpression(node) {
if (!node) return node;
if (ts.isParenthesizedExpression(node)) return unwrapExpression(node.expression);
if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isSatisfiesExpression?.(node)) {
return unwrapExpression(node.expression);
}
return node;
}
function isDbIdentifier(node, queryVars) {
return ts.isIdentifier(node) && (node.text === 'db' || node.text === 'tx' || queryVars.has(node.text));
}
function dependsOnDb(node, queryVars) {
const expr = unwrapExpression(node);
if (!expr) return false;
if (isDbIdentifier(expr, queryVars)) return true;
if (ts.isPropertyAccessExpression(expr) || ts.isElementAccessExpression(expr)) {
return dependsOnDb(expr.expression, queryVars);
}
if (ts.isCallExpression(expr) || ts.isNewExpression(expr)) {
if (dependsOnDb(expr.expression, queryVars)) return true;
return expr.arguments?.some((arg) => dependsOnDb(arg, queryVars)) ?? false;
}
if (ts.isConditionalExpression(expr)) {
return dependsOnDb(expr.condition, queryVars)
|| dependsOnDb(expr.whenTrue, queryVars)
|| dependsOnDb(expr.whenFalse, queryVars);
}
if (ts.isBinaryExpression(expr)) {
return dependsOnDb(expr.left, queryVars) || dependsOnDb(expr.right, queryVars);
}
if (ts.isArrayLiteralExpression(expr)) {
return expr.elements.some((el) => dependsOnDb(el, queryVars));
}
if (ts.isObjectLiteralExpression(expr)) {
return expr.properties.some((prop) => {
if (ts.isPropertyAssignment(prop)) return dependsOnDb(prop.initializer, queryVars);
if (ts.isShorthandPropertyAssignment(prop)) return queryVars.has(prop.name.text);
if (ts.isSpreadAssignment(prop)) return dependsOnDb(prop.expression, queryVars);
return false;
});
}
return false;
}
function collectQueryVars(sourceFile) {
const queryVars = new Set();
let changed = true;
while (changed) {
changed = false;
function visit(node) {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
if (!queryVars.has(node.name.text) && dependsOnDb(node.initializer, queryVars)) {
queryVars.add(node.name.text);
changed = true;
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
}
return queryVars;
}
function shouldAwaitDbCall(callNode, queryVars) {
if (!ts.isPropertyAccessExpression(callNode.expression)) return false;
const method = callNode.expression.name.text;
if (!METHOD_NAMES.has(method)) return false;
if (ts.isAwaitExpression(callNode.parent)) return false;
const targetExpr = callNode.expression.expression;
return dependsOnDb(targetExpr, queryVars);
}
function buildEditsForFile(filePath) {
const text = fs.readFileSync(filePath, 'utf8');
const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const queryVars = collectQueryVars(sourceFile);
const awaitedCalls = [];
const asyncFunctions = new Map();
function visit(node) {
if (ts.isCallExpression(node) && shouldAwaitDbCall(node, queryVars)) {
awaitedCalls.push(node);
const fn = findNearestFunction(node);
if (fn && !hasAsyncModifier(fn)) {
asyncFunctions.set(fn.pos + ':' + fn.end, fn);
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
const edits = [];
for (const callNode of awaitedCalls) {
const parent = callNode.parent;
const callStart = callNode.getStart(sourceFile);
const callEnd = callNode.getEnd();
const original = text.slice(callStart, callEnd);
const needsParens = (
(ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent))
&& parent.expression === callNode
);
const replacement = needsParens ? `(await ${original})` : `await ${original}`;
edits.push({ start: callStart, end: callEnd, text: replacement, type: 'await' });
}
for (const fn of asyncFunctions.values()) {
const insertPos = getFunctionTokenPos(fn, sourceFile);
edits.push({ start: insertPos, end: insertPos, text: 'async ', type: 'async' });
}
// De-duplicate same edit span/text
const uniq = new Map();
for (const edit of edits) {
const key = `${edit.start}:${edit.end}:${edit.text}`;
if (!uniq.has(key)) uniq.set(key, edit);
}
const finalEdits = [...uniq.values()].sort((a, b) => b.start - a.start || b.end - a.end);
if (finalEdits.length === 0) {
return { changed: false, text, nextText: text, awaitCount: 0, asyncCount: 0 };
}
let nextText = text;
for (const edit of finalEdits) {
nextText = nextText.slice(0, edit.start) + edit.text + nextText.slice(edit.end);
}
return {
changed: nextText !== text,
text,
nextText,
awaitCount: finalEdits.filter((e) => e.type === 'await').length,
asyncCount: finalEdits.filter((e) => e.type === 'async').length,
};
}
const files = listTsFiles(targetDir);
let changedFiles = 0;
let totalAwaitEdits = 0;
let totalAsyncEdits = 0;
const touched = [];
for (const file of files) {
const result = buildEditsForFile(file);
if (!result.changed) continue;
changedFiles += 1;
totalAwaitEdits += result.awaitCount;
totalAsyncEdits += result.asyncCount;
touched.push(path.relative(root, file));
if (mode === 'write') {
fs.writeFileSync(file, result.nextText, 'utf8');
}
}
console.log(JSON.stringify({
mode,
changedFiles,
totalAwaitEdits,
totalAsyncEdits,
sampleFiles: touched.slice(0, 20),
}, null, 2));
@@ -1,95 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
const mode = process.argv.includes('--write') ? 'write' : 'dry-run';
const root = process.cwd();
const targetDir = path.join(root, 'src', 'server');
const FUNCTION_KINDS = new Set([
ts.SyntaxKind.FunctionDeclaration,
ts.SyntaxKind.FunctionExpression,
ts.SyntaxKind.ArrowFunction,
ts.SyntaxKind.MethodDeclaration,
]);
function listTsFiles(dir) {
const out = [];
const stack = [dir];
while (stack.length > 0) {
const current = stack.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
continue;
}
if (entry.isFile() && full.endsWith('.ts')) {
out.push(full);
}
}
}
return out;
}
function hasAsyncModifier(node) {
return !!node.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.AsyncKeyword);
}
function isPromiseTypeNode(typeNode) {
if (!typeNode) return false;
if (!ts.isTypeReferenceNode(typeNode)) return false;
const typeName = typeNode.typeName;
return ts.isIdentifier(typeName) && typeName.text === 'Promise';
}
function applyEdits(text, edits) {
const sorted = [...edits].sort((a, b) => b.start - a.start || b.end - a.end);
let next = text;
for (const edit of sorted) {
next = next.slice(0, edit.start) + edit.text + next.slice(edit.end);
}
return next;
}
const files = listTsFiles(targetDir);
let changedFiles = 0;
let editsCount = 0;
const touched = [];
for (const filePath of files) {
const text = fs.readFileSync(filePath, 'utf8');
const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const edits = [];
function visit(node) {
if (FUNCTION_KINDS.has(node.kind) && hasAsyncModifier(node) && node.type && !isPromiseTypeNode(node.type)) {
const start = node.type.getStart(sourceFile);
const end = node.type.getEnd();
const original = text.slice(start, end);
edits.push({ start, end, text: `Promise<${original}>` });
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
if (edits.length === 0) continue;
const next = applyEdits(text, edits);
if (next === text) continue;
changedFiles += 1;
editsCount += edits.length;
touched.push(path.relative(root, filePath));
if (mode === 'write') {
fs.writeFileSync(filePath, next, 'utf8');
}
}
console.log(JSON.stringify({
mode,
changedFiles,
editsCount,
sampleFiles: touched.slice(0, 20),
}, null, 2));
-90
View File
@@ -1,90 +0,0 @@
#!/usr/bin/env node
/**
* Install ComfyUI locally for development
* Clones repo + sets up Python venv + installs deps
*/
import { execSync } from 'child_process';
import { existsSync, mkdirSync, copyFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(__dirname, '../..');
const COMFYUI_DIR = resolve(PROJECT_ROOT, '.comfyui');
async function run() {
console.log('[comfyui] Installing ComfyUI for local development...');
console.log(`[comfyui] Target: ${COMFYUI_DIR}`);
if (existsSync(COMFYUI_DIR)) {
console.log('[comfyui] ComfyUI already installed. Pulling latest...');
execSync('git pull', { cwd: COMFYUI_DIR, stdio: 'inherit' });
} else {
console.log('[comfyui] Cloning ComfyUI...');
execSync(
'git clone https://github.com/comfyanonymous/ComfyUI.git .comfyui',
{ cwd: PROJECT_ROOT, stdio: 'inherit' },
);
}
// Check Python
try {
execSync('python3 --version', { stdio: 'pipe' });
} catch {
console.error('[comfyui] ❌ python3 not found. Please install Python 3.10+.');
process.exit(1);
}
// Setup venv
const venvDir = resolve(COMFYUI_DIR, 'venv');
if (!existsSync(venvDir)) {
console.log('[comfyui] Creating Python venv...');
execSync('python3 -m venv venv', { cwd: COMFYUI_DIR, stdio: 'inherit' });
}
// Install deps (CPU-only — all generation goes through BoosAPI)
const pip = process.platform === 'win32'
? resolve(venvDir, 'Scripts', 'pip')
: resolve(venvDir, 'bin', 'pip');
const python = process.platform === 'win32'
? resolve(venvDir, 'Scripts', 'python')
: resolve(venvDir, 'bin', 'python');
console.log('[comfyui] Upgrading pip...');
execSync(`"${pip}" install --upgrade pip`, { cwd: COMFYUI_DIR, stdio: 'inherit' });
console.log('[comfyui] Installing PyTorch (CPU)...');
execSync(
`"${pip}" install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu`,
{ cwd: COMFYUI_DIR, stdio: 'inherit' },
);
console.log('[comfyui] Installing ComfyUI dependencies...');
execSync(
`grep -v kornia_rs requirements.txt | "${pip}" install -r /dev/stdin`,
{ cwd: COMFYUI_DIR, stdio: 'inherit' },
);
// Install BoosAPI custom nodes
const customNodesDir = resolve(COMFYUI_DIR, 'custom_nodes');
mkdirSync(customNodesDir, { recursive: true });
const customNodeSrc = resolve(PROJECT_ROOT, 'boosapi_comfy_nodes.py');
const customNodeDst = resolve(customNodesDir, 'boosapi_comfy_nodes.py');
if (existsSync(customNodeSrc)) {
copyFileSync(customNodeSrc, customNodeDst);
console.log('[comfyui] ✅ BoosAPI custom nodes installed');
}
console.log('');
console.log('[comfyui] ✅ ComfyUI installed!');
console.log(`[comfyui] Directory: ${COMFYUI_DIR}`);
console.log('[comfyui] Start with: npm run comfyui:start');
console.log('[comfyui] Or set COMFYUI_BASE env to point elsewhere');
}
run().catch((err) => {
console.error('[comfyui] ❌ Install failed:', err.message);
process.exit(1);
});
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env node
/**
* Start local ComfyUI (managed subprocess)
*/
import { spawn } from 'child_process';
import { existsSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(__dirname, '../..');
const COMFYUI_DIR = resolve(PROJECT_ROOT, '.comfyui');
function getPythonPath() {
const venvBin = resolve(COMFYUI_DIR, 'venv', 'bin');
const venvScripts = resolve(COMFYUI_DIR, 'venv', 'Scripts');
if (existsSync(resolve(venvBin, 'python3'))) return resolve(venvBin, 'python3');
if (existsSync(resolve(venvBin, 'python'))) return resolve(venvBin, 'python');
if (existsSync(resolve(venvScripts, 'python.exe'))) return resolve(venvScripts, 'python.exe');
return 'python3';
}
async function run() {
if (!existsSync(COMFYUI_DIR)) {
console.error('[comfyui] ❌ ComfyUI not installed. Run: npm run comfyui:install');
process.exit(1);
}
const python = getPythonPath();
const port = process.env.COMFYUI_PORT || '8188';
// Check if already running
try {
const resp = await fetch(`http://127.0.0.1:${port}/`);
if (resp.ok) {
console.log(`[comfyui] ✅ ComfyUI already running on port ${port}`);
process.exit(0);
}
} catch {
// Not running, start it
}
console.log(`[comfyui] Starting ComfyUI on port ${port}...`);
console.log(`[comfyui] Python: ${python}`);
console.log(`[comfyui] Dir: ${COMFYUI_DIR}`);
const proc = spawn(python, ['main.py', '--listen', '127.0.0.1', '--port', port, '--cpu'], {
cwd: COMFYUI_DIR,
stdio: 'inherit',
env: {
...process.env,
BOOSAPI_BASE: process.env.BOOSAPI_BASE || 'http://127.0.0.1:4000/v1',
BOOSAPI_KEY: process.env.BOOSAPI_KEY || '',
},
});
proc.on('exit', (code) => {
if (code !== 0 && code !== null) {
console.error(`[comfyui] ❌ Exited with code ${code}`);
}
});
// Graceful shutdown
process.on('SIGINT', () => { proc.kill(); process.exit(); });
process.on('SIGTERM', () => { proc.kill(); process.exit(); });
}
run().catch((err) => {
console.error('[comfyui] ❌ Start failed:', err.message);
process.exit(1);
});
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env node
/**
* Stop local ComfyUI by PID
*/
import { readFileSync, existsSync, unlinkSync } from 'fs';
import { execSync } from 'child_process';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const COMFYUI_DIR = resolve(__dirname, '../..', '.comfyui');
const PID_FILE = resolve(COMFYUI_DIR, 'comfyui.pid');
// Try PID file first
if (existsSync(PID_FILE)) {
try {
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
process.kill(pid, 'SIGTERM');
console.log(`[comfyui] Stopped PID ${pid}`);
unlinkSync(PID_FILE);
process.exit(0);
} catch (e) {
if (e.code === 'ESRCH') {
console.log('[comfyui] Process already stopped');
unlinkSync(PID_FILE);
process.exit(0);
}
// Fall through to pkill
}
}
// Fallback: kill by process name
try {
execSync('pkill -f "python.*main.py.*--listen.*8188"', { stdio: 'pipe' });
console.log('[comfyui] Stopped ComfyUI process');
} catch {
console.log('[comfyui] No running ComfyUI process found');
}
-7
View File
@@ -1,7 +0,0 @@
import { ensureAdHocSignature, getMacAppPath } from './macosSignUtils.mjs';
export default async function afterPack(context) {
if (context.electronPlatformName !== 'darwin') return;
await ensureAdHocSignature(getMacAppPath(context));
}
-30
View File
@@ -1,30 +0,0 @@
import { notarize } from '@electron/notarize';
import { ensureAdHocSignature, getMacAppPath } from './macosSignUtils.mjs';
export default async function afterSign(context) {
if (context.electronPlatformName !== 'darwin') return;
const appPath = getMacAppPath(context);
const appleId = process.env.APPLE_ID || process.env.APPLEID;
const appleIdPassword = process.env.APPLE_APP_SPECIFIC_PASSWORD || process.env.APPLEIDPASS;
const teamId = process.env.APPLE_TEAM_ID;
const signature = await ensureAdHocSignature(appPath);
if (!appleId || !appleIdPassword || !teamId) {
console.log('[metapi-desktop] Skipping notarization: APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID not fully configured.');
return;
}
if (signature.isAdHoc) {
console.log('[metapi-desktop] Skipping notarization: macOS app is only ad-hoc signed. Configure a Developer ID certificate to enable notarization.');
return;
}
console.log('[metapi-desktop] Notarizing macOS build...');
await notarize({
appPath,
appleId,
appleIdPassword,
teamId,
});
}
-126
View File
@@ -1,126 +0,0 @@
import { mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import sharp from 'sharp';
export const DESKTOP_ICON_SIZE = 512;
export const DESKTOP_ICON_PADDING = 40;
export const DESKTOP_ICON_RADIUS = 96;
export const DESKTOP_TRAY_TEMPLATE_PADDING = 152;
function createRoundedMask(size, cornerRadius) {
return Buffer.from(
`<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" rx="${cornerRadius}" ry="${cornerRadius}" fill="#fff"/></svg>`,
);
}
async function renderDesktopIconBuffer({
sourcePath,
size = DESKTOP_ICON_SIZE,
padding = DESKTOP_ICON_PADDING,
cornerRadius = DESKTOP_ICON_RADIUS,
}) {
const innerSize = size - padding * 2;
const roundedMask = createRoundedMask(innerSize, Math.min(cornerRadius, Math.floor(innerSize / 2)));
const roundedInner = await sharp(sourcePath)
.resize(innerSize, innerSize, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.composite([{ input: roundedMask, blend: 'dest-in' }])
.png()
.toBuffer();
return sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite([{ input: roundedInner, left: padding, top: padding }])
.png()
.toBuffer();
}
async function renderTrayTemplateIconBuffer({
sourcePath,
size = DESKTOP_ICON_SIZE,
padding = DESKTOP_TRAY_TEMPLATE_PADDING,
}) {
const innerSize = Math.max(1, size - padding * 2);
const alphaMask = await sharp(sourcePath)
.resize(innerSize, innerSize, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.ensureAlpha()
.extractChannel('alpha')
.png()
.toBuffer();
return sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 1 },
},
})
.composite([{ input: alphaMask, left: padding, top: padding, blend: 'dest-in' }])
.png()
.toBuffer();
}
export async function generateDesktopIconAssets({
sourcePath = join(process.cwd(), 'src', 'web', 'public', 'logo.png'),
buildOutputPath = join(process.cwd(), 'build', 'desktop-icon.png'),
webOutputPath = join(process.cwd(), 'src', 'web', 'public', 'desktop-icon.png'),
trayTemplateOutputPath = join(process.cwd(), 'src', 'web', 'public', 'desktop-tray-template.png'),
size = DESKTOP_ICON_SIZE,
padding = DESKTOP_ICON_PADDING,
cornerRadius = DESKTOP_ICON_RADIUS,
} = {}) {
const [outputBuffer, trayTemplateBuffer] = await Promise.all([
renderDesktopIconBuffer({
sourcePath,
size,
padding,
cornerRadius,
}),
renderTrayTemplateIconBuffer({
sourcePath,
size,
}),
]);
await Promise.all([
mkdir(dirname(buildOutputPath), { recursive: true }),
mkdir(dirname(webOutputPath), { recursive: true }),
mkdir(dirname(trayTemplateOutputPath), { recursive: true }),
]);
await Promise.all([
sharp(outputBuffer).toFile(buildOutputPath),
sharp(outputBuffer).toFile(webOutputPath),
sharp(trayTemplateBuffer).toFile(trayTemplateOutputPath),
]);
return {
buildOutputPath,
webOutputPath,
trayTemplateOutputPath,
};
}
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
const outputs = await generateDesktopIconAssets();
console.log(`[metapi-desktop] Generated desktop icons:
- ${outputs.buildOutputPath}
- ${outputs.webOutputPath}
- ${outputs.trayTemplateOutputPath}`);
}
-86
View File
@@ -1,86 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import sharp from 'sharp';
import { afterEach, describe, expect, it } from 'vitest';
const tempDirs: string[] = [];
async function makeTempDir() {
const dir = await mkdtemp(join(tmpdir(), 'metapi-desktop-icons-'));
tempDirs.push(dir);
return dir;
}
function alphaAt(buffer: Buffer, width: number, x: number, y: number) {
return buffer[(y * width + x) * 4 + 3];
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe('generateDesktopIconAssets', () => {
it('writes rounded desktop icon outputs and a tray template asset', async () => {
const { generateDesktopIconAssets } = await import('./generate-icons.mjs');
const dir = await makeTempDir();
const sourcePath = join(dir, 'logo.png');
const buildOutputPath = join(dir, 'build.png');
const webOutputPath = join(dir, 'desktop-icon.png');
const trayTemplateOutputPath = join(dir, 'desktop-tray-template.png');
await sharp({
create: {
width: 512,
height: 512,
channels: 4,
background: { r: 255, g: 112, b: 48, alpha: 1 },
},
}).png().toFile(sourcePath);
await generateDesktopIconAssets({
sourcePath,
buildOutputPath,
webOutputPath,
trayTemplateOutputPath,
});
const buildMeta = await sharp(buildOutputPath).metadata();
const webMeta = await sharp(webOutputPath).metadata();
const trayMeta = await sharp(trayTemplateOutputPath).metadata();
expect(buildMeta.width).toBe(512);
expect(buildMeta.height).toBe(512);
expect(webMeta.width).toBe(512);
expect(webMeta.height).toBe(512);
expect(trayMeta.width).toBe(512);
expect(trayMeta.height).toBe(512);
const { data, info } = await sharp(buildOutputPath)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
expect(alphaAt(data, info.width, 0, 0)).toBe(0);
expect(alphaAt(data, info.width, 12, 12)).toBe(0);
expect(alphaAt(data, info.width, Math.floor(info.width / 2), 6)).toBe(0);
expect(alphaAt(data, info.width, Math.floor(info.width / 2), Math.floor(info.height / 2))).toBe(255);
expect(await sharp(buildOutputPath).png().toBuffer())
.toEqual(await sharp(webOutputPath).png().toBuffer());
const trayBuffer = await sharp(trayTemplateOutputPath)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
expect(alphaAt(trayBuffer.data, trayBuffer.info.width, 0, 0)).toBe(0);
expect(alphaAt(trayBuffer.data, trayBuffer.info.width, Math.floor(trayBuffer.info.width / 2), Math.floor(trayBuffer.info.height / 2))).toBe(255);
const trayCenterOffset = (Math.floor(trayBuffer.info.height / 2) * trayBuffer.info.width + Math.floor(trayBuffer.info.width / 2)) * 4;
expect(trayBuffer.data[trayCenterOffset]).toBe(0);
expect(trayBuffer.data[trayCenterOffset + 1]).toBe(0);
expect(trayBuffer.data[trayCenterOffset + 2]).toBe(0);
});
});
-41
View File
@@ -1,41 +0,0 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
export function getMacAppPath(context) {
return `${context.appOutDir}/${context.packager.appInfo.productFilename}.app`;
}
export async function inspectSignature(appPath) {
try {
const { stdout, stderr } = await execFileAsync('codesign', ['-dv', '--verbose=4', appPath]);
const output = `${stdout}\n${stderr}`;
return {
signed: true,
isAdHoc: /Signature=adhoc/i.test(output) || !/Authority=/i.test(output),
details: output,
};
} catch {
return {
signed: false,
isAdHoc: false,
details: '',
};
}
}
export async function adHocSign(appPath) {
await execFileAsync('codesign', ['--force', '--deep', '--sign', '-', appPath]);
}
export async function ensureAdHocSignature(appPath) {
const signature = await inspectSignature(appPath);
if (signature.signed) {
return signature;
}
console.log('[metapi-desktop] No macOS signing identity detected, applying ad-hoc signature.');
await adHocSign(appPath);
return inspectSignature(appPath);
}
-26
View File
@@ -1,26 +0,0 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('desktop package scripts', () => {
it('rebuilds the app before package:desktop targets invoke electron-builder', () => {
const packageJson = JSON.parse(
readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'),
) as { scripts?: Record<string, string> };
const scripts = packageJson.scripts || {};
const packageDesktop = scripts['package:desktop'] || '';
const packageDesktopIntel = scripts['package:desktop:mac:intel'] || '';
const distDesktop = scripts['dist:desktop'] || '';
const distDesktopIntel = scripts['dist:desktop:mac:intel'] || '';
expect(
packageDesktop === 'npm run dist:desktop' || packageDesktop.includes('npm run build'),
).toBe(true);
expect(
packageDesktopIntel === 'npm run dist:desktop:mac:intel' || packageDesktopIntel.includes('npm run build'),
).toBe(true);
expect(distDesktop).toContain('npm run build');
expect(distDesktopIntel).toContain('npm run build');
});
});
-31
View File
@@ -1,31 +0,0 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('release workflow', () => {
it('builds macOS arm64 and x64 on dedicated runners and verifies packaged app architecture', () => {
const workflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8');
expect(workflow).toContain('runner: macos-15-intel');
expect(workflow).toContain('runner: macos-15');
expect(workflow).toContain('expectedMacArch: x64');
expect(workflow).toContain('expectedMacArch: arm64');
expect(workflow).toContain('Verify packaged macOS architecture');
expect(workflow).toContain('node scripts/desktop/verifyMacArchitecture.mjs');
});
it('uploads Linux desktop artifacts for AppImage, deb, and rpm packages', () => {
const workflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8');
expect(workflow).toContain('release/*.AppImage');
expect(workflow).toContain('release/*.deb');
expect(workflow).toContain('release/*.rpm');
});
it('installs rpm tooling on Linux runners before packaging Fedora desktop artifacts', () => {
const workflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8');
expect(workflow).toContain("if: runner.os == 'Linux'");
expect(workflow).toContain('sudo apt-get install --no-install-recommends -y rpm');
});
});
-122
View File
@@ -1,122 +0,0 @@
import { execFileSync } from 'node:child_process';
import { readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const MAC_BINARY_SEGMENTS = ['Metapi.app', 'Contents', 'MacOS', 'Metapi'];
function walkDirectories(rootDir) {
const queue = [rootDir];
const files = [];
while (queue.length > 0) {
const currentDir = queue.shift();
if (!currentDir) {
continue;
}
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
const entryPath = join(currentDir, entry.name);
if (entry.isDirectory()) {
queue.push(entryPath);
continue;
}
if (entry.isFile()) {
files.push(entryPath);
}
}
}
return files;
}
export function findPackagedMacBinaries(releaseDir) {
const normalizedSuffix = MAC_BINARY_SEGMENTS.join('/');
return walkDirectories(releaseDir).filter((filePath) =>
filePath.replaceAll('\\', '/').endsWith(normalizedSuffix),
);
}
export function normalizeExpectedMacArch(expectedArch) {
if (expectedArch === 'x64') {
return 'x86_64';
}
if (expectedArch === 'arm64') {
return 'arm64';
}
throw new Error(`Unsupported expected mac architecture: ${expectedArch}`);
}
export function inspectBinaryArchsWithLipo(binaryPath) {
try {
return execFileSync('lipo', ['-archs', binaryPath], { encoding: 'utf8' }).trim();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Unable to detect architecture via lipo for ${binaryPath}: ${message}`);
}
}
export function verifyMacArchitecture({
releaseDir,
expectedArch,
inspectBinaryArchs = inspectBinaryArchsWithLipo,
}) {
const binaries = findPackagedMacBinaries(releaseDir);
if (binaries.length === 0) {
throw new Error(`No packaged macOS app binary found under ${releaseDir}`);
}
const expectedBinaryArch = normalizeExpectedMacArch(expectedArch);
return binaries.map((binaryPath) => {
const archs = inspectBinaryArchs(binaryPath).trim().replace(/\s+/g, ' ');
if (!archs) {
throw new Error(`Unable to detect architecture via lipo for ${binaryPath}`);
}
if (archs !== expectedBinaryArch) {
throw new Error(`Expected ${expectedBinaryArch}-only binary but got: ${archs}`);
}
return { binaryPath, archs };
});
}
function parseArgValue(args, name) {
const index = args.indexOf(name);
if (index === -1) {
return undefined;
}
return args[index + 1];
}
function main() {
const args = process.argv.slice(2);
const releaseDirArg = parseArgValue(args, '--release-dir');
const expectedArch = parseArgValue(args, '--expected-arch');
if (!releaseDirArg || !expectedArch) {
throw new Error('Usage: node scripts/desktop/verifyMacArchitecture.mjs --release-dir <dir> --expected-arch <x64|arm64>');
}
const releaseDir = resolve(releaseDirArg);
const verifiedBinaries = verifyMacArchitecture({ releaseDir, expectedArch });
for (const { binaryPath, archs } of verifiedBinaries) {
console.log(`${binaryPath} => ${archs}`);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
@@ -1,54 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { verifyMacArchitecture } from './verifyMacArchitecture.mjs';
const tempDirs: string[] = [];
function createReleaseBinary() {
const releaseDir = mkdtempSync(join(tmpdir(), 'metapi-mac-arch-'));
tempDirs.push(releaseDir);
const binaryPath = join(releaseDir, 'mac-x64', 'Metapi.app', 'Contents', 'MacOS', 'Metapi');
mkdirSync(dirname(binaryPath), { recursive: true });
writeFileSync(binaryPath, 'binary');
return { releaseDir, binaryPath };
}
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
describe('verifyMacArchitecture', () => {
it('accepts x64 binaries when lipo reports x86_64', () => {
const { releaseDir, binaryPath } = createReleaseBinary();
expect(
verifyMacArchitecture({
releaseDir,
expectedArch: 'x64',
inspectBinaryArchs: () => 'x86_64',
}),
).toEqual([{ archs: 'x86_64', binaryPath }]);
});
it('rejects x64 binaries when lipo reports arm64', () => {
const { releaseDir } = createReleaseBinary();
expect(() =>
verifyMacArchitecture({
releaseDir,
expectedArch: 'x64',
inspectBinaryArchs: () => 'arm64',
}),
).toThrow(/Expected x86_64-only binary but got: arm64/);
});
});
@@ -1,55 +0,0 @@
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, describe, expect, it } from 'vitest';
import { copyRuntimeDbGeneratedAssets } from './copy-runtime-db-generated.js';
const tempDirs: string[] = [];
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
describe('copyRuntimeDbGeneratedAssets', () => {
it('copies generated runtime db artifacts and shared runtime modules into dist', () => {
const repoRoot = mkdtempSync(join(tmpdir(), 'metapi-runtime-db-assets-'));
tempDirs.push(repoRoot);
const sourceDir = join(repoRoot, 'src', 'server', 'db', 'generated');
const nestedDir = join(sourceDir, 'fixtures');
const sharedDir = join(repoRoot, 'src', 'shared');
const staleSharedDistDir = join(repoRoot, 'dist', 'shared');
mkdirSync(nestedDir, { recursive: true });
mkdirSync(sharedDir, { recursive: true });
mkdirSync(staleSharedDistDir, { recursive: true });
writeFileSync(join(sourceDir, 'mysql.bootstrap.sql'), 'create table demo ();');
writeFileSync(join(nestedDir, 'baseline.json'), '{"ok":true}');
writeFileSync(join(sharedDir, 'tokenRouteContract.d.ts'), 'export declare const demo: number;\n');
writeFileSync(join(sharedDir, 'tokenRouteContract.js'), 'export const demo = 1;\n');
writeFileSync(join(sharedDir, 'tokenRouteContract.test.ts'), 'throw new Error("should not ship");\n');
writeFileSync(join(staleSharedDistDir, 'tokenRouteContract.test.ts'), 'stale test artifact\n');
copyRuntimeDbGeneratedAssets(repoRoot);
expect(
readFileSync(join(repoRoot, 'dist', 'server', 'db', 'generated', 'mysql.bootstrap.sql'), 'utf8'),
).toBe('create table demo ();');
expect(
readFileSync(join(repoRoot, 'dist', 'server', 'db', 'generated', 'fixtures', 'baseline.json'), 'utf8'),
).toBe('{"ok":true}');
expect(
readFileSync(join(repoRoot, 'dist', 'shared', 'tokenRouteContract.js'), 'utf8'),
).toBe('export const demo = 1;\n');
expect(
readFileSync(join(repoRoot, 'dist', 'shared', 'tokenRouteContract.d.ts'), 'utf8'),
).toBe('export declare const demo: number;\n');
expect(
existsSync(join(repoRoot, 'dist', 'shared', 'tokenRouteContract.test.ts')),
).toBe(false);
});
});
-41
View File
@@ -1,41 +0,0 @@
import { cpSync, existsSync, mkdirSync, rmSync, statSync } from 'node:fs';
import { basename, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
function resolveRepoRoot(): string {
return resolve(dirname(fileURLToPath(import.meta.url)), '../..');
}
function shouldCopySharedArtifact(sourcePath: string): boolean {
if (statSync(sourcePath).isDirectory()) return true;
const filename = basename(sourcePath);
return filename.endsWith('.js') || filename.endsWith('.d.ts');
}
export function copyRuntimeDbGeneratedAssets(repoRoot: string = resolveRepoRoot()): void {
const sourceDir = resolve(repoRoot, 'src/server/db/generated');
const targetDir = resolve(repoRoot, 'dist/server/db/generated');
const sharedSourceDir = resolve(repoRoot, 'src/shared');
const sharedTargetDir = resolve(repoRoot, 'dist/shared');
if (!existsSync(sourceDir)) {
throw new Error(`Runtime DB generated assets directory does not exist: ${sourceDir}`);
}
mkdirSync(dirname(targetDir), { recursive: true });
cpSync(sourceDir, targetDir, { recursive: true, force: true });
if (existsSync(sharedSourceDir)) {
rmSync(sharedTargetDir, { recursive: true, force: true });
mkdirSync(dirname(sharedTargetDir), { recursive: true });
cpSync(sharedSourceDir, sharedTargetDir, {
recursive: true,
force: true,
filter: (sourcePath) => shouldCopySharedArtifact(sourcePath),
});
}
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
copyRuntimeDbGeneratedAssets();
}
-124
View File
@@ -1,124 +0,0 @@
import { sql } from 'drizzle-orm';
type DbType = 'sqlite' | 'mysql' | 'postgres';
type CliOptions = {
dbType?: string;
dbUrl?: string;
};
function parseArgs(argv: string[]): CliOptions {
const options: CliOptions = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i] || '';
if (!arg.startsWith('--')) continue;
const [rawKey, rawValue] = arg.slice(2).split('=', 2);
const key = rawKey.trim();
const value = rawValue !== undefined ? rawValue : (argv[i + 1]?.startsWith('--') ? '' : argv[++i] || '');
if (key === 'db-type') options.dbType = value.trim();
if (key === 'db-url') options.dbUrl = value.trim();
}
return options;
}
function normalizeDbType(input: string | undefined): DbType {
const normalized = (input || '').trim().toLowerCase();
if (normalized === 'mysql') return 'mysql';
if (normalized === 'postgres' || normalized === 'postgresql') return 'postgres';
return 'sqlite';
}
function normalizeFirstScalar(value: unknown): number | string | null {
if (value === undefined || value === null) return null;
if (typeof value === 'number' || typeof value === 'string') return value;
if (Array.isArray(value)) {
if (value.length === 0) return null;
return normalizeFirstScalar(value[0]);
}
if (typeof value === 'object') {
const record = value as Record<string, unknown>;
if (Array.isArray(record.rows)) {
return normalizeFirstScalar(record.rows);
}
const scalar = Object.values(record)[0];
return normalizeFirstScalar(scalar);
}
return null;
}
async function selectScalar(db: any, query: ReturnType<typeof sql>): Promise<number | string | null> {
if (typeof db?.get === 'function') {
return normalizeFirstScalar(await db.get(query));
}
if (typeof db?.all === 'function') {
return normalizeFirstScalar(await db.all(query));
}
if (typeof db?.values === 'function') {
return normalizeFirstScalar(await db.values(query));
}
if (typeof db?.execute === 'function') {
return normalizeFirstScalar(await db.execute(query));
}
throw new Error('database client does not expose a scalar query method');
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.dbType) process.env.DB_TYPE = options.dbType;
if (options.dbUrl !== undefined) process.env.DB_URL = options.dbUrl;
const { config } = await import('../../src/server/config.js');
const { db, runtimeDbDialect, closeDbConnections } = await import('../../src/server/db/index.js');
try {
const dbType = normalizeDbType(config.dbType || options.dbType);
const dbUrl = (config.dbUrl || '').trim();
if ((dbType === 'mysql' || dbType === 'postgres') && !dbUrl) {
throw new Error(`DB_URL is required for DB_TYPE=${dbType}`);
}
console.log(`[db-smoke] start dbType=${dbType} runtime=${runtimeDbDialect}`);
if (dbUrl) {
console.log(`[db-smoke] dbUrl=${dbUrl}`);
} else {
console.log('[db-smoke] dbUrl=(empty, using default sqlite path)');
}
const pingScalar = await selectScalar(db, sql`select 1 as ok`);
if (Number(pingScalar) !== 1) {
throw new Error(`unexpected ping result: ${JSON.stringify(pingScalar)}`);
}
console.log('[db-smoke] ping ok');
const txScalar = await db.transaction(async (tx: any) => selectScalar(tx, sql`select 1 as ok`));
if (Number(txScalar) !== 1) {
throw new Error(`unexpected transaction ping result: ${JSON.stringify(txScalar)}`);
}
console.log('[db-smoke] transaction ok');
const version = dbType === 'sqlite'
? await selectScalar(db, sql`select sqlite_version() as v`)
: await selectScalar(db, sql`select version() as v`);
if (typeof version !== 'string' || version.trim().length === 0) {
throw new Error(`failed to read server version: ${JSON.stringify(version)}`);
}
console.log(`[db-smoke] version=${version.slice(0, 120)}`);
console.log('[db-smoke] success');
} finally {
await closeDbConnections();
}
}
main().catch((error) => {
console.error('[db-smoke] failed');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
-53
View File
@@ -1,53 +0,0 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('docker workflows', () => {
it('publishes armv7 docker images in ci and release workflows', () => {
const ciWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/ci.yml'), 'utf8');
const releaseWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8');
expect(ciWorkflow).toContain('arch: armv7');
expect(ciWorkflow).toContain('platform: linux/arm/v7');
expect(ciWorkflow).toContain('"${tag}-armv7"');
expect(releaseWorkflow).toContain('arch: armv7');
expect(releaseWorkflow).toContain('platform: linux/arm/v7');
expect(releaseWorkflow).toContain('"${tag}-armv7"');
});
it('derives Docker Hub image names from the configured username secret', () => {
const ciWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/ci.yml'), 'utf8');
const releaseWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8');
expect(ciWorkflow).toContain('DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi');
expect(ciWorkflow).not.toContain('images: 1467078763/metapi');
expect(releaseWorkflow).toContain('DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi');
expect(releaseWorkflow).not.toContain('1467078763/metapi');
});
it('uses an armv7-capable node base image in the Dockerfile', () => {
const dockerfile = readFileSync(resolve(process.cwd(), 'docker/Dockerfile'), 'utf8');
expect(dockerfile).toContain('FROM node:22-bookworm-slim AS builder');
expect(dockerfile).toContain('FROM node:22-bookworm-slim');
});
it('avoids buildkit-only frontend syntax so managed docker builders can parse it reliably', () => {
const dockerfile = readFileSync(resolve(process.cwd(), 'docker/Dockerfile'), 'utf8');
expect(dockerfile).not.toContain('# syntax=docker/dockerfile:');
expect(dockerfile).not.toContain('RUN --mount=type=cache');
});
it('keeps server docker builds isolated from desktop packaging dependencies', () => {
const dockerfile = readFileSync(resolve(process.cwd(), 'docker/Dockerfile'), 'utf8');
expect(dockerfile).toContain('npm ci --ignore-scripts --no-audit --no-fund');
expect(dockerfile).toContain('npm rebuild esbuild sharp better-sqlite3 --no-audit --no-fund');
expect(dockerfile).not.toContain('npm ci --no-audit --no-fund');
expect(dockerfile).toContain('RUN npm run build:web && npm run build:server');
expect(dockerfile).toContain('npm prune --omit=dev --no-audit --no-fund');
});
});
-23
View File
@@ -1,23 +0,0 @@
import { existsSync, readFileSync } from 'node:fs';
import { writeDialectArtifactFiles } from '../../src/server/db/schemaArtifactGenerator.js';
import {
resolveGeneratedSchemaContractPath,
type SchemaContract,
writeSchemaContractFile,
} from '../../src/server/db/schemaContract.js';
function readPreviousSchemaContract(): SchemaContract | null {
const contractPath = resolveGeneratedSchemaContractPath();
if (!existsSync(contractPath)) {
return null;
}
return JSON.parse(readFileSync(contractPath, 'utf8')) as SchemaContract;
}
const previousContract = readPreviousSchemaContract();
const contract = writeSchemaContractFile();
writeDialectArtifactFiles(contract, previousContract);
const tableCount = Object.keys(contract.tables).length;
console.log(`[schema:contract] wrote ${tableCount} tables and dialect artifacts`);
-113
View File
@@ -1,113 +0,0 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import {
resolveGeneratedSchemaContractPath,
type SchemaContract,
} from '../../src/server/db/schemaContract.js';
interface FixtureScriptOptions {
fromRef?: string;
outputPath: string;
dropTables: string[];
dropColumns: string[];
}
function parseArgs(argv: string[]): FixtureScriptOptions {
const options: FixtureScriptOptions = {
outputPath: 'src/server/db/generated/fixtures/2026-03-14-baseline.schemaContract.json',
dropTables: [],
dropColumns: [],
};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--from-ref') {
options.fromRef = argv[index + 1];
index += 1;
continue;
}
if (argument === '--output') {
options.outputPath = argv[index + 1];
index += 1;
continue;
}
if (argument === '--drop-table') {
options.dropTables.push(argv[index + 1]);
index += 1;
continue;
}
if (argument === '--drop-column') {
options.dropColumns.push(argv[index + 1]);
index += 1;
}
}
return options;
}
function pruneContract(contract: SchemaContract, options: FixtureScriptOptions): SchemaContract {
const droppedTables = new Set(options.dropTables);
const droppedColumns = new Map<string, Set<string>>();
for (const entry of options.dropColumns) {
const [tableName, columnName] = entry.split('.', 2);
if (!tableName || !columnName) {
throw new Error(`invalid --drop-column value: ${entry}`);
}
const tableColumns = droppedColumns.get(tableName) ?? new Set<string>();
tableColumns.add(columnName);
droppedColumns.set(tableName, tableColumns);
}
const tables = Object.fromEntries(
Object.entries(contract.tables)
.filter(([tableName]) => !droppedTables.has(tableName))
.map(([tableName, table]) => {
const removedColumns = droppedColumns.get(tableName) ?? new Set<string>();
const columns = Object.fromEntries(
Object.entries(table.columns).filter(([columnName]) => !removedColumns.has(columnName)),
);
return [tableName, { columns }];
}),
);
const hasColumn = (tableName: string, columnName: string): boolean =>
!!tables[tableName]?.columns[columnName];
return {
tables,
indexes: contract.indexes.filter((index) =>
!!tables[index.table] && index.columns.every((columnName) => hasColumn(index.table, columnName))),
uniques: contract.uniques.filter((unique) =>
!!tables[unique.table] && unique.columns.every((columnName) => hasColumn(unique.table, columnName))),
foreignKeys: contract.foreignKeys.filter((foreignKey) =>
!!tables[foreignKey.table]
&& !!tables[foreignKey.referencedTable]
&& foreignKey.columns.every((columnName) => hasColumn(foreignKey.table, columnName))
&& foreignKey.referencedColumns.every((columnName) => hasColumn(foreignKey.referencedTable, columnName))),
};
}
function readContractFromRef(fromRef: string): SchemaContract {
const contractJson = execFileSync(
'git',
['-c', 'safe.directory=.', 'show', `${fromRef}:src/server/db/generated/schemaContract.json`],
{ encoding: 'utf8' },
);
return JSON.parse(contractJson) as SchemaContract;
}
function readCurrentContract(): SchemaContract {
return JSON.parse(readFileSync(resolveGeneratedSchemaContractPath(), 'utf8')) as SchemaContract;
}
const options = parseArgs(process.argv.slice(2));
const sourceContract = options.fromRef ? readContractFromRef(options.fromRef) : readCurrentContract();
const contract = pruneContract(sourceContract, options);
const outputPath = resolve(process.cwd(), options.outputPath);
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, `${JSON.stringify(contract, null, 2)}\n`, 'utf8');
console.log(`[schema:upgrade-fixture] wrote ${outputPath}`);
-22
View File
@@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('harness workflows', () => {
it('keeps repo drift checks wired into ci and scheduled reporting', () => {
const ciWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/ci.yml'), 'utf8');
const driftWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/harness-drift-report.yml'), 'utf8');
expect(ciWorkflow).toContain('name: Repo Drift Check');
expect(ciWorkflow).toContain('npm run repo:drift-check');
expect(ciWorkflow).toContain('name: Test Core');
expect(ciWorkflow).toContain('name: Build Web');
expect(ciWorkflow).toContain('name: Typecheck');
expect(driftWorkflow).toContain('schedule:');
expect(driftWorkflow).toContain('workflow_dispatch:');
expect(driftWorkflow).toContain('npm run repo:drift-check -- --format markdown --output tmp/repo-drift-report.md --report-only');
expect(driftWorkflow).toMatch(/actions\/upload-artifact@v\d+/);
expect(driftWorkflow).toContain('repo-drift-report');
});
});
-64
View File
@@ -1,64 +0,0 @@
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { formatRepoDriftReport, runRepoDriftCheck } from './repo-drift-check.js';
function writeWorkspaceFiles(root: string, files: Record<string, string>): void {
for (const [relativePath, contents] of Object.entries(files)) {
const fullPath = join(root, relativePath);
mkdirSync(dirname(fullPath), { recursive: true });
writeFileSync(fullPath, contents);
}
}
describe('repo drift check', () => {
it('separates new violations from tracked debt', () => {
const root = mkdtempSync(join(tmpdir(), 'metapi-repo-drift-'));
writeWorkspaceFiles(root, {
'src/server/transformers/openai/responses/routeCompatibility.ts': "import type { EndpointAttemptContext } from '../../../routes/proxy/endpointFlow.js';\n",
'src/server/proxy-core/surfaces/chatSurface.ts': 'const payload = await upstream.text();\n',
'src/server/proxy-core/surfaces/sharedSurface.ts': "import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';\n",
'src/web/pages/Accounts.tsx': "import { TokensPanel } from './Tokens.js';\n",
'src/web/pages/Tokens.tsx': 'export const TokensPanel = () => null;\n',
});
const report = runRepoDriftCheck({ root });
expect(report.violations).toEqual(expect.arrayContaining([
expect.objectContaining({
ruleId: 'proxy-surface-body-read',
file: 'src/server/proxy-core/surfaces/chatSurface.ts',
}),
expect.objectContaining({
ruleId: 'transformers-route-blind',
file: 'src/server/transformers/openai/responses/routeCompatibility.ts',
}),
]));
expect(report.trackedDebt).toEqual(expect.arrayContaining([
expect.objectContaining({
ruleId: 'proxy-core-routes-proxy-import',
file: 'src/server/proxy-core/surfaces/sharedSurface.ts',
}),
expect.objectContaining({
ruleId: 'web-page-to-page-import',
file: 'src/web/pages/Accounts.tsx',
}),
]));
});
it('keeps the current repository within the first-wave ratchet', () => {
const report = runRepoDriftCheck({ root: process.cwd() });
expect(report.violations).toEqual([]);
expect(report.trackedDebt).toEqual(expect.any(Array));
});
it('can render markdown reports for scheduled cleanup jobs', () => {
const report = runRepoDriftCheck({ root: process.cwd() });
const markdown = formatRepoDriftReport(report, 'markdown');
expect(markdown).toContain('# Repo Drift Report');
expect(markdown).toContain('## Violations');
expect(markdown).toContain('## Tracked Debt');
});
});
-338
View File
@@ -1,338 +0,0 @@
import { lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, extname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import process from 'node:process';
type ReportFormat = 'text' | 'markdown';
type Severity = 'violation' | 'tracked_debt';
type DriftFinding = {
ruleId: string;
severity: Severity;
file: string;
line: number;
message: string;
excerpt: string;
};
type DriftReport = {
root: string;
generatedAt: string;
violations: DriftFinding[];
trackedDebt: DriftFinding[];
};
type RuleSpec = {
id: string;
description: string;
fileFilter: (file: string) => boolean;
lineMatch: (line: string, file: string) => boolean;
message: string | ((file: string, line: string) => string);
allowlistedFiles?: Set<string>;
};
type RunOptions = {
root: string;
};
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']);
const ROUTES_PROXY_IMPORT_ALLOWLIST = new Set([
'src/server/proxy-core/surfaces/chatSurface.ts',
'src/server/proxy-core/surfaces/filesSurface.ts',
'src/server/proxy-core/surfaces/geminiSurface.ts',
'src/server/proxy-core/surfaces/openAiResponsesSurface.ts',
'src/server/proxy-core/surfaces/sharedSurface.ts',
]);
const TOP_LEVEL_PAGE_IMPORT_ALLOWLIST = new Set([
'src/web/pages/Accounts.tsx',
]);
function normalizeRelativePath(root: string, fullPath: string): string {
return relative(root, fullPath).replaceAll('\\', '/');
}
function walkFiles(root: string, currentDir = root): string[] {
const entries = readdirSync(currentDir).sort((left, right) => left.localeCompare(right, 'en'));
const files: string[] = [];
for (const entry of entries) {
if (entry === '.git' || entry === 'node_modules' || entry === 'dist' || entry === 'coverage') {
continue;
}
const fullPath = resolve(currentDir, entry);
const stat = lstatSync(fullPath);
if (stat.isSymbolicLink()) {
continue;
}
if (stat.isDirectory()) {
files.push(...walkFiles(root, fullPath));
continue;
}
const extension = extname(entry);
if (SOURCE_EXTENSIONS.has(extension)) {
files.push(fullPath);
}
}
return files;
}
function readWorkspaceLines(root: string, file: string): Array<{ lineNumber: number; text: string }> {
const source = readFileSync(resolve(root, file), 'utf8').replaceAll('\r\n', '\n');
return source.split('\n').map((text, index) => ({
lineNumber: index + 1,
text,
}));
}
function isNonTestSource(file: string): boolean {
return !file.endsWith('.test.ts')
&& !file.endsWith('.test.tsx')
&& !file.endsWith('.test.js')
&& !file.endsWith('.test.jsx');
}
function isTopLevelPageFile(file: string): boolean {
return /^src\/web\/pages\/[^/]+\.(ts|tsx|js|jsx)$/.test(file)
&& isNonTestSource(file);
}
function createRules(): RuleSpec[] {
return [
{
id: 'transformers-route-blind',
description: 'Transformers must not import route-layer proxy helpers',
fileFilter: (file) => file.startsWith('src/server/transformers/')
&& isNonTestSource(file),
lineMatch: (line) => /from\s+['"][^'"]*routes\/proxy\//.test(line),
message: 'transformer imports route-layer proxy code',
},
{
id: 'proxy-surface-body-read',
description: 'Proxy-core surfaces should use readRuntimeResponseText() for whole-body reads',
fileFilter: (file) => file.startsWith('src/server/proxy-core/surfaces/')
&& isNonTestSource(file),
lineMatch: (line) => /\.text\(/.test(line),
message: 'proxy-core surface reads a full upstream body via .text()',
},
{
id: 'proxy-core-routes-proxy-import',
description: 'Proxy-core imports from routes/proxy are tracked debt and should not grow',
fileFilter: (file) => file.startsWith('src/server/proxy-core/')
&& isNonTestSource(file),
lineMatch: (line) => /from\s+['"][^'"]*routes\/proxy\//.test(line),
message: 'proxy-core imports a helper from routes/proxy',
allowlistedFiles: ROUTES_PROXY_IMPORT_ALLOWLIST,
},
{
id: 'web-page-to-page-import',
description: 'Top-level route pages should not import other top-level route pages',
fileFilter: (file) => isTopLevelPageFile(file),
lineMatch: (line, file) => {
const match = line.match(/from\s+['"]\.\/([^/'"]+?)(?:\.(?:js|ts|tsx|jsx))?['"]/);
if (!match) return false;
const importedPage = match[1];
const currentPage = file.replace(/^src\/web\/pages\//, '').replace(/\.(ts|tsx|js|jsx)$/, '');
return importedPage !== currentPage;
},
message: (file, line) => {
const match = line.match(/from\s+['"](\.\/[^'"]+)['"]/);
const imported = match?.[1] ?? 'another page file';
return `top-level page imports ${imported}`;
},
allowlistedFiles: TOP_LEVEL_PAGE_IMPORT_ALLOWLIST,
},
];
}
export function runRepoDriftCheck(options: Partial<RunOptions> = {}): DriftReport {
const root = resolve(options.root ?? process.cwd());
const report: DriftReport = {
root,
generatedAt: new Date().toISOString(),
violations: [],
trackedDebt: [],
};
const files = walkFiles(root).map((file) => normalizeRelativePath(root, file));
const rules = createRules();
for (const rule of rules) {
for (const file of files) {
if (!rule.fileFilter(file)) continue;
for (const { lineNumber, text } of readWorkspaceLines(root, file)) {
if (!rule.lineMatch(text, file)) continue;
const finding: DriftFinding = {
ruleId: rule.id,
severity: rule.allowlistedFiles?.has(file) ? 'tracked_debt' : 'violation',
file,
line: lineNumber,
message: typeof rule.message === 'function' ? rule.message(file, text) : rule.message,
excerpt: text.trim(),
};
if (finding.severity === 'tracked_debt') {
report.trackedDebt.push(finding);
} else {
report.violations.push(finding);
}
}
}
}
return report;
}
function formatFindingText(finding: DriftFinding): string {
return `- [${finding.ruleId}] ${finding.file}:${finding.line} ${finding.message}\n ${finding.excerpt}`;
}
function formatFindingMarkdownRow(finding: DriftFinding): string {
const escapedMessage = finding.message.replaceAll('|', '\\|');
const escapedExcerpt = finding.excerpt.replaceAll('|', '\\|');
return `| \`${finding.ruleId}\` | \`${finding.file}:${finding.line}\` | ${escapedMessage} | \`${escapedExcerpt}\` |`;
}
export function formatRepoDriftReport(report: DriftReport, format: ReportFormat = 'text'): string {
if (format === 'markdown') {
const lines: string[] = [
'# Repo Drift Report',
'',
`- Root: \`${report.root}\``,
`- Generated at: \`${report.generatedAt}\``,
`- Violations: **${report.violations.length}**`,
`- Tracked debt: **${report.trackedDebt.length}**`,
'',
];
if (report.violations.length > 0) {
lines.push('## Violations', '', '| Rule | Location | Message | Excerpt |', '| --- | --- | --- | --- |');
for (const finding of report.violations) {
lines.push(formatFindingMarkdownRow(finding));
}
lines.push('');
} else {
lines.push('## Violations', '', 'No new violations found.', '');
}
if (report.trackedDebt.length > 0) {
lines.push('## Tracked Debt', '', '| Rule | Location | Message | Excerpt |', '| --- | --- | --- | --- |');
for (const finding of report.trackedDebt) {
lines.push(formatFindingMarkdownRow(finding));
}
lines.push('');
} else {
lines.push('## Tracked Debt', '', 'No tracked debt entries were observed.', '');
}
return lines.join('\n');
}
const lines: string[] = [
`Repo drift report for ${report.root}`,
`Generated at ${report.generatedAt}`,
'',
`Violations: ${report.violations.length}`,
];
if (report.violations.length > 0) {
lines.push(...report.violations.map(formatFindingText));
} else {
lines.push('- none');
}
lines.push('', `Tracked debt: ${report.trackedDebt.length}`);
if (report.trackedDebt.length > 0) {
lines.push(...report.trackedDebt.map(formatFindingText));
} else {
lines.push('- none');
}
return lines.join('\n');
}
type CliOptions = {
format: ReportFormat;
output?: string;
reportOnly: boolean;
root?: string;
};
function parseCliOptions(argv: string[]): CliOptions {
const options: CliOptions = {
format: 'text',
reportOnly: false,
};
const readRequiredValue = (flag: string, index: number): string => {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${flag} requires a value`);
}
return value;
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--format') {
const value = readRequiredValue(arg, index);
if (value !== 'text' && value !== 'markdown') {
throw new Error(`--format must be one of: text, markdown`);
}
options.format = value;
index += 1;
continue;
}
if (arg === '--output') {
options.output = readRequiredValue(arg, index);
index += 1;
continue;
}
if (arg === '--report-only') {
options.reportOnly = true;
continue;
}
if (arg === '--root') {
options.root = readRequiredValue(arg, index);
index += 1;
}
}
return options;
}
function maybeWriteReport(outputPath: string | undefined, contents: string): void {
if (!outputPath) return;
const resolved = resolve(outputPath);
mkdirSync(dirname(resolved), { recursive: true });
writeFileSync(resolved, contents);
}
const isMainModule = (() => {
try {
return process.argv[1] != null && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
} catch {
return false;
}
})();
if (isMainModule) {
try {
const options = parseCliOptions(process.argv.slice(2));
const report = runRepoDriftCheck({ root: options.root });
const contents = formatRepoDriftReport(report, options.format);
maybeWriteReport(options.output, contents);
console.log(contents);
process.exit(report.violations.length > 0 && !options.reportOnly ? 1 : 0);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
process.exit(1);
}
}
-46
View File
@@ -1,46 +0,0 @@
@echo off
setlocal EnableExtensions
rem Try to switch output to UTF-8; ignore failures.
chcp 65001 >nul 2>&1
set "SCRIPT_DIR=%~dp0"
set "PROJECT_ROOT=%SCRIPT_DIR%..\.."
cd /d "%PROJECT_ROOT%"
echo [INFO] Stopping old processes...
rem Read ports from .env with defaults.
set "BACKEND_PORT=9000"
set "PROXY_PORT=9001"
set "FRONTEND_PORT=5173"
for /f "tokens=2 delims==" %%v in ('findstr /B /C:"PORT=" .env 2^>nul') do set "BACKEND_PORT=%%v"
for /f "tokens=2 delims==" %%v in ('findstr /B /C:"PROXY_PORT=" .env 2^>nul') do set "PROXY_PORT=%%v"
for /f "tokens=2 delims==" %%v in ('findstr /B /C:"FRONTEND_PORT=" .env 2^>nul') do set "FRONTEND_PORT=%%v"
rem Kill listeners on backend/proxy/frontend ports.
for %%p in (%BACKEND_PORT% %PROXY_PORT% %FRONTEND_PORT%) do (
for /f "tokens=5" %%a in ('netstat -aon ^| findstr ":%%p " ^| findstr /I LISTENING 2^>nul') do (
if not "%%a"=="0" (
echo Killing PID %%a on port %%p
taskkill /F /PID %%a >nul 2>&1
)
)
)
ping -n 3 127.0.0.1 >nul
echo [INFO] Syncing database schema...
call npx drizzle-kit push --force 2>nul || echo (drizzle-kit push skipped)
echo [INFO] Starting development servers
echo Backend: http://localhost:%BACKEND_PORT%
echo Frontend: http://localhost:%FRONTEND_PORT%
echo.
if /I "%~1"=="--no-dev" (
echo --no-dev set; skipping npm run dev
exit /b 0
)
call npm run dev
-82
View File
@@ -1,82 +0,0 @@
import { spawn } from 'node:child_process';
import { join } from 'node:path';
import {
buildBetterSqliteRebuildCommand,
isNodeModuleVersionMismatch,
} from '../../src/server/nativeModuleGuard.js';
function run(command: string, args: string[]) {
return new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
cwd: process.cwd(),
stdio: 'inherit',
shell: false,
env: process.env,
});
child.once('exit', (code, signal) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`Command failed: ${command} ${args.join(' ')} (code=${code ?? 'null'}, signal=${signal ?? 'null'})`));
});
child.once('error', reject);
});
}
async function ensureBetterSqliteCompatible() {
try {
await import('better-sqlite3');
return;
} catch (error) {
if (!isNodeModuleVersionMismatch(error)) {
throw error;
}
}
const rebuild = buildBetterSqliteRebuildCommand();
console.log('[boosapi] Detected better-sqlite3 ABI mismatch. Rebuilding for the current Node.js runtime...');
await run(rebuild.command, rebuild.args);
try {
await import('better-sqlite3');
} catch (error) {
throw new Error(
`better-sqlite3 is still incompatible after rebuild: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async function main() {
const isWatchMode = process.argv.includes('--watch');
await ensureBetterSqliteCompatible();
if (isWatchMode) {
const tsxCli = join(process.cwd(), 'node_modules', 'tsx', 'dist', 'cli.mjs');
const child = spawn(process.execPath, [tsxCli, 'watch', 'src/server/index.ts'], {
cwd: process.cwd(),
stdio: 'inherit',
env: process.env,
});
child.once('exit', (code) => {
process.exit(code ?? 0);
});
return;
}
const child = spawn(process.execPath, ['dist/server/index.js'], {
cwd: process.cwd(),
stdio: 'inherit',
env: process.env,
});
child.once('exit', (code) => {
process.exit(code ?? 0);
});
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -1,123 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
parsePaginatedJson,
normalizeContributors,
replaceContributorsSection,
renderContributorsBlock,
} from './update-readme-contributors.js';
describe('README contributors updater', () => {
it('filters anonymous and bot contributors before rendering', () => {
const contributors = normalizeContributors([
{
login: 'cita-777',
html_url: 'https://github.com/cita-777',
avatar_url: 'https://avatars.githubusercontent.com/u/177306803?v=4',
contributions: 483,
},
{
login: 'dependabot[bot]',
html_url: 'https://github.com/apps/dependabot',
avatar_url: 'https://avatars.githubusercontent.com/in/29110?v=4',
contributions: 12,
},
{
login: null,
html_url: null,
avatar_url: null,
contributions: 22,
},
{
login: 'Babylonehy',
html_url: 'https://github.com/Babylonehy',
avatar_url: 'https://avatars.githubusercontent.com/u/30937892?v=4',
contributions: 3,
},
]);
expect(contributors).toEqual([
{
login: 'cita-777',
htmlUrl: 'https://github.com/cita-777',
avatarUrl: 'https://avatars.githubusercontent.com/u/177306803?v=4&s=48',
},
{
login: 'Babylonehy',
htmlUrl: 'https://github.com/Babylonehy',
avatarUrl: 'https://avatars.githubusercontent.com/u/30937892?v=4&s=48',
},
]);
});
it('replaces the marked README section with an avatar wall', () => {
const readme = [
'# Metapi',
'',
'<!-- metapi-contributors:start -->',
'old block',
'<!-- metapi-contributors:end -->',
'',
'tail',
].join('\n');
const next = replaceContributorsSection(readme, [
{
login: 'cita-777',
htmlUrl: 'https://github.com/cita-777',
avatarUrl: 'https://avatars.githubusercontent.com/u/177306803?v=4&s=48',
},
{
login: 'Hureru',
htmlUrl: 'https://github.com/Hureru',
avatarUrl: 'https://avatars.githubusercontent.com/u/121702350?v=4&s=48',
},
{
login: 'Babylonehy',
htmlUrl: 'https://github.com/Babylonehy',
avatarUrl: 'https://avatars.githubusercontent.com/u/30937892?v=4&s=48',
},
], 2);
expect(next).toContain('<p align="left">');
expect(next).toContain('https://github.com/cita-777');
expect(next).toContain('https://github.com/Hureru');
expect(next).toContain('https://github.com/Babylonehy');
expect(next).not.toContain('old block');
expect(next).toContain('tail');
});
it('renders contributors across multiple rows', () => {
const html = renderContributorsBlock([
{
login: 'cita-777',
htmlUrl: 'https://github.com/cita-777',
avatarUrl: 'https://avatars.githubusercontent.com/u/177306803?v=4&s=48',
},
{
login: 'Hureru',
htmlUrl: 'https://github.com/Hureru',
avatarUrl: 'https://avatars.githubusercontent.com/u/121702350?v=4&s=48',
},
{
login: 'Babylonehy',
htmlUrl: 'https://github.com/Babylonehy',
avatarUrl: 'https://avatars.githubusercontent.com/u/30937892?v=4&s=48',
},
], 2);
expect(html).toContain('\n <a href="https://github.com/cita-777"');
expect(html).toContain('\n <a href="https://github.com/Babylonehy"');
expect((html.match(/\n <a href=/g) || []).length).toBe(2);
});
it('rejects invalid per-line values instead of looping forever', () => {
expect(() => renderContributorsBlock([], 0)).toThrow('perLine must be a positive integer');
});
it('parses concatenated paginated gh api arrays without requiring newlines between pages', () => {
expect(parsePaginatedJson('[{"login":"a"}][{"login":"b"}]')).toEqual([
{ login: 'a' },
{ login: 'b' },
]);
});
});
-197
View File
@@ -1,197 +0,0 @@
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO = 'cita-777/boosapi';
const README_FILES = ['README.md', 'README_EN.md'] as const;
const START_MARKER = '<!-- boosapi-contributors:start -->';
const END_MARKER = '<!-- boosapi-contributors:end -->';
const PER_LINE = 10;
const EXCLUDED_LOGINS = new Set(['dependabot[bot]']);
type GitHubContributor = {
login: string | null;
html_url: string | null;
avatar_url: string | null;
contributions?: number;
};
export type ReadmeContributor = {
login: string;
htmlUrl: string;
avatarUrl: string;
};
export function normalizeContributors(
contributors: GitHubContributor[],
): ReadmeContributor[] {
const seen = new Set<string>();
return contributors
.filter((contributor) => {
const login = contributor.login?.trim();
const htmlUrl = contributor.html_url?.trim();
const avatarUrl = contributor.avatar_url?.trim();
if (!login || !htmlUrl || !avatarUrl) {
return false;
}
if (EXCLUDED_LOGINS.has(login.toLowerCase()) || login.toLowerCase().endsWith('[bot]')) {
return false;
}
return true;
})
.sort((left, right) => (right.contributions ?? 0) - (left.contributions ?? 0))
.filter((contributor) => {
const key = contributor.login!.toLowerCase();
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
})
.map((contributor) => ({
login: contributor.login!.trim(),
htmlUrl: contributor.html_url!.trim(),
avatarUrl: normalizeAvatarUrl(contributor.avatar_url!.trim()),
}));
}
export function renderContributorsBlock(
contributors: ReadmeContributor[],
perLine = PER_LINE,
): string {
if (!Number.isInteger(perLine) || perLine <= 0) {
throw new RangeError('perLine must be a positive integer');
}
if (contributors.length === 0) {
return '<p align="left">\n <sub>No public contributors found yet.</sub>\n</p>';
}
const lines: string[] = [];
for (let index = 0; index < contributors.length; index += perLine) {
const chunk = contributors.slice(index, index + perLine);
lines.push(
` ${chunk
.map(
(contributor) =>
`<a href="${contributor.htmlUrl}"><img src="${contributor.avatarUrl}" width="48" height="48" alt="${contributor.login}" title="${contributor.login}"/></a>`,
)
.join(' ')}`,
);
}
return `<p align="left">\n${lines.join('\n')}\n</p>`;
}
export function replaceContributorsSection(
readme: string,
contributors: ReadmeContributor[],
perLine = PER_LINE,
): string {
const start = readme.indexOf(START_MARKER);
const end = readme.indexOf(END_MARKER);
if (start === -1 || end === -1 || end < start) {
throw new Error('README contributors markers are missing');
}
const before = readme.slice(0, start + START_MARKER.length);
const after = readme.slice(end);
return `${before}\n${renderContributorsBlock(contributors, perLine)}\n${after}`;
}
export function updateReadmeFile(
readmePath: string,
contributors: ReadmeContributor[],
perLine = PER_LINE,
): boolean {
const current = readFileSync(readmePath, 'utf8');
const next = replaceContributorsSection(current, contributors, perLine);
if (current === next) {
return false;
}
writeFileSync(readmePath, next);
return true;
}
export function fetchContributors(repo = REPO): ReadmeContributor[] {
const raw = execFileSync(
'gh',
['api', `repos/${repo}/contributors?per_page=100&anon=1`, '--paginate'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
);
return normalizeContributors(parsePaginatedJson(raw) as GitHubContributor[]);
}
export function parsePaginatedJson(raw: string): unknown[] {
const trimmedRaw = raw.trim();
if (!trimmedRaw) return [];
try {
const parsed = JSON.parse(trimmedRaw);
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
// Fall through to paginated-array parsing.
}
try {
const mergedArrays = JSON.parse(`[${trimmedRaw.replace(/\]\s*\[/g, '],[')}]`);
if (Array.isArray(mergedArrays)) {
return mergedArrays.flatMap((item) => (Array.isArray(item) ? item : [item]));
}
} catch {
// Fall through to line-by-line parsing.
}
const items: unknown[] = [];
for (const line of trimmedRaw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
items.push(...parsed);
} else {
items.push(parsed);
}
}
return items;
}
function normalizeAvatarUrl(url: string): string {
if (!/^https?:/i.test(url)) {
return url;
}
const lower = url.toLowerCase();
if (lower.includes('s=') || lower.includes('size=')) {
return url;
}
return `${url}${url.includes('?') ? '&' : '?'}s=48`;
}
function main(): void {
const contributors = fetchContributors(REPO);
const updatedFiles = README_FILES.filter((path) =>
updateReadmeFile(resolve(process.cwd(), path), contributors),
);
const touched = updatedFiles.length > 0 ? updatedFiles.join(', ') : 'none';
console.log(`Updated README contributors for ${contributors.length} people: ${touched}`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
main();
}