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:
@@ -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));
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
@@ -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}`);
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user