feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env -S npx tsx
|
||||
/**
|
||||
* CLI entry for the benchmark runner.
|
||||
*
|
||||
* npm run bench # all tasks
|
||||
* npm run bench -- --task=composite-mini-report
|
||||
* npm run bench -- --server=http://127.0.0.1:9876
|
||||
* BENCH_JUDGE=off npm run bench # skip LLM judge (axis D fixed at 1.0)
|
||||
*
|
||||
* Prereq: scripts/server.sh start (or `npm start`) running on the configured port.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { startFixtureServer } from '../src/bench/fixture-server.js';
|
||||
import { runOneTask } from '../src/bench/runner.js';
|
||||
import {
|
||||
computeTotal,
|
||||
gradeChecklist,
|
||||
gradeInstructionsProgrammatic,
|
||||
gradeTools,
|
||||
} from '../src/bench/grader.js';
|
||||
import { gradeReasoning, loadJudgeConfigFromEnv } from '../src/bench/judge.js';
|
||||
import { writeRunSummary } from '../src/bench/summary.js';
|
||||
import type { BenchResult, BenchTask, RawJobResult } from '../src/bench/types.js';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import { logger } from '../src/logger.js';
|
||||
|
||||
interface Args {
|
||||
taskFilter?: string;
|
||||
serverUrl: string;
|
||||
benchRoot: string;
|
||||
resultDir: string;
|
||||
}
|
||||
|
||||
function parseArgs(): Args {
|
||||
const argv = process.argv.slice(2);
|
||||
let taskFilter: string | undefined;
|
||||
let serverUrl = process.env['BENCH_SERVER_URL'] ?? 'http://127.0.0.1:9876';
|
||||
for (const a of argv) {
|
||||
if (a.startsWith('--task=')) taskFilter = a.slice('--task='.length);
|
||||
else if (a.startsWith('--server=')) serverUrl = a.slice('--server='.length);
|
||||
}
|
||||
const benchRoot = path.resolve(process.cwd(), 'bench');
|
||||
const runId = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const resultDir = path.join(benchRoot, 'results', runId);
|
||||
return { taskFilter, serverUrl, benchRoot, resultDir };
|
||||
}
|
||||
|
||||
function loadTasks(benchRoot: string, filter?: string): BenchTask[] {
|
||||
const taskDir = path.join(benchRoot, 'tasks');
|
||||
if (!fs.existsSync(taskDir)) return [];
|
||||
const files = fs.readdirSync(taskDir).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'));
|
||||
const tasks: BenchTask[] = [];
|
||||
for (const f of files) {
|
||||
const parsed = parseYaml(fs.readFileSync(path.join(taskDir, f), 'utf-8')) as BenchTask;
|
||||
if (filter && parsed.id !== filter) continue;
|
||||
tasks.push(parsed);
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
function copyWorkspaceArtifacts(rawWorkspace: string, dest: string): void {
|
||||
if (!rawWorkspace || !fs.existsSync(rawWorkspace)) return;
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
function copyDir(src: string, target: string): void {
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
const s = path.join(src, entry.name);
|
||||
const t = path.join(target, entry.name);
|
||||
if (entry.isDirectory()) copyDir(s, t);
|
||||
else if (entry.isFile()) {
|
||||
try {
|
||||
fs.copyFileSync(s, t);
|
||||
} catch (err) {
|
||||
logger.warn(`[bench] copy failed ${s} -> ${t}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const sub of ['logs', 'output']) {
|
||||
const src = path.join(rawWorkspace, sub);
|
||||
if (fs.existsSync(src)) copyDir(src, path.join(dest, sub));
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs();
|
||||
const config = loadConfig();
|
||||
const fallbackWorker = config.provider.workers?.[0];
|
||||
const judgeConfig = loadJudgeConfigFromEnv({
|
||||
endpoint: fallbackWorker?.endpoint ?? config.provider.baseUrl ?? 'http://127.0.0.1:11434/v1',
|
||||
model: fallbackWorker?.model ?? config.provider.model ?? '',
|
||||
apiKey: config.provider.apiKey ?? undefined,
|
||||
});
|
||||
|
||||
const tasks = loadTasks(args.benchRoot, args.taskFilter);
|
||||
if (tasks.length === 0) {
|
||||
logger.error(`[bench] no tasks found (filter=${args.taskFilter ?? 'none'})`);
|
||||
process.exit(1);
|
||||
}
|
||||
fs.mkdirSync(args.resultDir, { recursive: true });
|
||||
logger.info(`[bench] run dir: ${args.resultDir}`);
|
||||
logger.info(`[bench] server: ${args.serverUrl}`);
|
||||
logger.info(`[bench] judge: ${judgeConfig ? `${judgeConfig.endpoint} model=${judgeConfig.model}` : 'off'}`);
|
||||
|
||||
const fixtureServer = await startFixtureServer(path.join(args.benchRoot, 'fixtures', 'web'));
|
||||
const results: BenchResult[] = [];
|
||||
try {
|
||||
for (const task of tasks) {
|
||||
const startedAt = new Date().toISOString();
|
||||
logger.info(`[bench] === task: ${task.id} ===`);
|
||||
let raw: RawJobResult;
|
||||
try {
|
||||
raw = await runOneTask(task, {
|
||||
serverUrl: args.serverUrl,
|
||||
benchRoot: args.benchRoot,
|
||||
webPort: fixtureServer.port,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`[bench] runner failed for ${task.id}: ${(err as Error).message}`);
|
||||
continue;
|
||||
}
|
||||
const tools = gradeTools(task, raw);
|
||||
const checklist = gradeChecklist(task, raw);
|
||||
const instructions = gradeInstructionsProgrammatic(task, raw);
|
||||
const reasoning = await gradeReasoning(task, raw, judgeConfig);
|
||||
const finishedAt = new Date().toISOString();
|
||||
const result: BenchResult = {
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
startedAt,
|
||||
finishedAt,
|
||||
raw,
|
||||
axes: { tools, checklist, instructions, reasoning },
|
||||
total: 0,
|
||||
};
|
||||
result.total = computeTotal(result.axes);
|
||||
const taskResultDir = path.join(args.resultDir, task.id);
|
||||
fs.mkdirSync(taskResultDir, { recursive: true });
|
||||
copyWorkspaceArtifacts(raw.workspacePath, path.join(taskResultDir, 'workspace'));
|
||||
fs.writeFileSync(path.join(taskResultDir, 'result.json'), JSON.stringify(result, null, 2), 'utf-8');
|
||||
results.push(result);
|
||||
}
|
||||
} finally {
|
||||
await fixtureServer.close();
|
||||
}
|
||||
|
||||
const summaryPath = writeRunSummary(args.resultDir, results);
|
||||
logger.info(`[bench] wrote ${summaryPath}`);
|
||||
// Concise stdout summary so CI / humans see totals without opening the file.
|
||||
for (const r of results) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`${r.taskId}: total=${r.total}/100 status=${r.raw.status} tools=${(r.axes.tools.score * 100).toFixed(0)}% checklist=${(r.axes.checklist.score * 100).toFixed(0)}% instr=${(r.axes.instructions.score * 100).toFixed(0)}% reasoning=${(r.axes.reasoning.score * 100).toFixed(0)}%`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
SKIP_INSTALL=0
|
||||
SKIP_PYTHON=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--skip-install)
|
||||
SKIP_INSTALL=1
|
||||
;;
|
||||
--skip-python)
|
||||
SKIP_PYTHON=1
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $arg" >&2
|
||||
echo "Usage: ./scripts/build-all.sh [--skip-install] [--skip-python]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$SKIP_INSTALL" -eq 0 ]; then
|
||||
echo "[1/5] Installing backend dependencies"
|
||||
npm ci
|
||||
|
||||
echo "[2/5] Installing UI dependencies"
|
||||
npm --prefix ui ci
|
||||
else
|
||||
echo "[1/5] Skipping dependency installation"
|
||||
echo "[2/5] Reusing existing node_modules"
|
||||
fi
|
||||
|
||||
echo "[3/5] Building backend"
|
||||
npm run build:server
|
||||
|
||||
echo "[4/5] Building UI"
|
||||
npm --prefix ui run build
|
||||
|
||||
if [ "$SKIP_PYTHON" -eq 0 ]; then
|
||||
echo "[5/5] Pre-baking Python packages for the Bash sandbox"
|
||||
# Non-fatal: the script warns and exits 0 if pip/python is unavailable, so a
|
||||
# missing python toolchain never breaks the JS build. Skip with --skip-python.
|
||||
bash "$PROJECT_DIR/scripts/prebake-python.sh" || true
|
||||
else
|
||||
echo "[5/5] Skipping Python pre-bake (--skip-python)"
|
||||
fi
|
||||
|
||||
echo "Build complete: backend dist/ and ui/dist/ are ready"
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env -S npx ts-node
|
||||
/**
|
||||
* Generate binary fixtures for benchmark tasks.
|
||||
* Usage: npx ts-node scripts/build-bench-fixtures.ts
|
||||
*
|
||||
* Generates bench/fixtures/sales.xlsx with deterministic data so that
|
||||
* "Q1 売上トップ3" is well-defined: ProductC > ProductA > ProductE.
|
||||
*/
|
||||
import ExcelJS from 'exceljs';
|
||||
import { join } from 'path';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
wb.creator = 'bench-fixture-builder';
|
||||
wb.created = new Date('2026-04-01T00:00:00Z');
|
||||
|
||||
const sheet = wb.addWorksheet('Sheet1');
|
||||
sheet.columns = [
|
||||
{ header: '商品名', key: 'name', width: 12 },
|
||||
{ header: 'カテゴリ', key: 'category', width: 10 },
|
||||
{ header: 'Q1売上(千円)', key: 'sales', width: 14 },
|
||||
];
|
||||
const rows = [
|
||||
{ name: 'ProductA', category: '食品', sales: 12000 },
|
||||
{ name: 'ProductB', category: '雑貨', sales: 8500 },
|
||||
{ name: 'ProductC', category: '食品', sales: 15000 },
|
||||
{ name: 'ProductD', category: '雑貨', sales: 9000 },
|
||||
{ name: 'ProductE', category: '食品', sales: 11000 },
|
||||
{ name: 'ProductF', category: '雑貨', sales: 7000 },
|
||||
];
|
||||
for (const row of rows) sheet.addRow(row);
|
||||
|
||||
const out = join(process.cwd(), 'bench', 'fixtures', 'sales.xlsx');
|
||||
await wb.xlsx.writeFile(out);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Wrote ${out}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+162
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env bash
|
||||
# Launch AAO in gateway mode (OpenAI-compatible LLM gateway, no UI).
|
||||
#
|
||||
# This is a sibling of scripts/server.sh (worker mode). Both can run
|
||||
# concurrently on the same host because:
|
||||
# - PID file: .gateway.pid (worker uses .server.pid)
|
||||
# - Log file: logs/gateway.log (worker uses logs/server.log)
|
||||
# - Listen port: $GATEWAY_PORT (default 4000)、worker は $PORT (default 9876)
|
||||
# - DB: 同じ data/aao.db を SQLite WAL mode で共有
|
||||
# (gateway は gateway_virtual_keys + gateway_key_usage の 2 table のみ touch)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/gateway.sh start
|
||||
# scripts/gateway.sh stop
|
||||
# scripts/gateway.sh restart
|
||||
# scripts/gateway.sh status
|
||||
# scripts/gateway.sh logs
|
||||
#
|
||||
# Env override:
|
||||
# GATEWAY_PORT=4000 # gateway.listen_port を上書き (config.yaml より優先)
|
||||
# AAO_CONFIG=/etc/aao/config-gateway.yaml # 専用 config を使う場合
|
||||
# AAO_GATEWAY_NO_DB=1 # ステートレス起動 (virtual_keys は config 経由のみ、budget/rate-limit 無効)
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
PID_FILE="$PROJECT_DIR/.gateway.pid"
|
||||
LOG_FILE="$PROJECT_DIR/logs/gateway.log"
|
||||
GATEWAY_PORT="${GATEWAY_PORT:-4000}"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 {start|stop|restart|status|logs}"
|
||||
echo
|
||||
echo "Env:"
|
||||
echo " GATEWAY_PORT=$GATEWAY_PORT"
|
||||
echo " AAO_CONFIG=${AAO_CONFIG:-<repo config.yaml>}"
|
||||
echo " AAO_GATEWAY_NO_DB=${AAO_GATEWAY_NO_DB:-<unset, DB enabled>}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
is_running() {
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
local pid
|
||||
pid=$(cat "$PID_FILE")
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
do_start() {
|
||||
if is_running; then
|
||||
echo "Gateway already running (PID $(cat "$PID_FILE"))"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
|
||||
echo "Checking runtime dependencies..."
|
||||
"$SCRIPT_DIR/prepare.sh"
|
||||
|
||||
echo "Building..."
|
||||
npm run build --silent 2>&1 | tail -1
|
||||
|
||||
echo "Starting gateway on port $GATEWAY_PORT..."
|
||||
# AAO_MODE=gateway は src/main.ts の switch を gateway/bootstrap に向ける
|
||||
# GATEWAY_PORT は src/gateway/config.ts が config.yaml の listen_port を
|
||||
# env で上書きするためのキー (実装側で env > config の precedence)。
|
||||
AAO_MODE=gateway \
|
||||
GATEWAY_PORT="$GATEWAY_PORT" \
|
||||
${AAO_CONFIG:+AAO_CONFIG="$AAO_CONFIG"} \
|
||||
${AAO_GATEWAY_NO_DB:+AAO_GATEWAY_NO_DB="$AAO_GATEWAY_NO_DB"} \
|
||||
nohup node dist/main.js >> "$LOG_FILE" 2>&1 &
|
||||
local pid=$!
|
||||
echo "$pid" > "$PID_FILE"
|
||||
|
||||
# Wait briefly and verify it started
|
||||
sleep 2
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "Gateway started (PID $pid, log: $LOG_FILE)"
|
||||
echo " Health: curl http://localhost:$GATEWAY_PORT/health/liveness"
|
||||
echo " Metrics: curl http://localhost:$GATEWAY_PORT/metrics"
|
||||
else
|
||||
rm -f "$PID_FILE"
|
||||
echo "Gateway failed to start. Last 20 log lines:"
|
||||
tail -20 "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
do_stop() {
|
||||
if ! is_running; then
|
||||
echo "Gateway not running"
|
||||
# Also kill any stray process on the port
|
||||
local stray
|
||||
stray=$(lsof -ti:"$GATEWAY_PORT" 2>/dev/null || true)
|
||||
if [[ -n "$stray" ]]; then
|
||||
echo "Found stray process on port $GATEWAY_PORT (PID $stray), killing..."
|
||||
kill "$stray" 2>/dev/null || true
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pid
|
||||
pid=$(cat "$PID_FILE")
|
||||
echo "Stopping gateway (PID $pid)..."
|
||||
# SIGTERM で graceful shutdown (gateway.shutdown_graceful_sec で SSE drain)
|
||||
kill "$pid" 2>/dev/null || true
|
||||
|
||||
# Wait for graceful shutdown — gateway drain may take up to ~30s by default
|
||||
for i in {1..60}; do
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
rm -f "$PID_FILE"
|
||||
echo "Gateway stopped (graceful)"
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
# Force kill
|
||||
echo "Graceful shutdown timed out; force killing..."
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
rm -f "$PID_FILE"
|
||||
echo "Gateway stopped (forced)"
|
||||
}
|
||||
|
||||
do_status() {
|
||||
if is_running; then
|
||||
local pid
|
||||
pid=$(cat "$PID_FILE")
|
||||
echo "Gateway running (PID $pid, port $GATEWAY_PORT)"
|
||||
# Probe liveness for confirmation
|
||||
if curl -sS -o /dev/null -w "%{http_code}" "http://localhost:$GATEWAY_PORT/health/liveness" 2>/dev/null | grep -q 200; then
|
||||
echo " Liveness: OK"
|
||||
else
|
||||
echo " Liveness: NOT OK (process up but endpoint not responding)"
|
||||
fi
|
||||
else
|
||||
echo "Gateway not running"
|
||||
fi
|
||||
}
|
||||
|
||||
do_logs() {
|
||||
if [[ -f "$LOG_FILE" ]]; then
|
||||
tail -f "$LOG_FILE"
|
||||
else
|
||||
echo "No log file found at $LOG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
start) do_start ;;
|
||||
stop) do_stop ;;
|
||||
restart) do_stop; do_start ;;
|
||||
status) do_status ;;
|
||||
logs) do_logs ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Generate version from last git commit timestamp (UTC)
|
||||
VERSION=$(TZ=UTC git log -1 --format=%cd --date=format:'%Y%m%d.%H%M%S' 2>/dev/null || echo "dev")
|
||||
|
||||
OUTDIR="src/generated"
|
||||
mkdir -p "$OUTDIR"
|
||||
cat > "$OUTDIR/version.ts" << EOF
|
||||
// Auto-generated by scripts/generate-version.sh — do not edit
|
||||
export const APP_VERSION = '${VERSION}';
|
||||
EOF
|
||||
|
||||
echo "Generated version: ${VERSION}"
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
MODE="install"
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--upgrade)
|
||||
MODE="upgrade"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $arg" >&2
|
||||
echo "Usage: ./scripts/install-twitter-cli.sh [--upgrade]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
choose_installer() {
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
echo "uv"
|
||||
return
|
||||
fi
|
||||
if command -v pipx >/dev/null 2>&1; then
|
||||
echo "pipx"
|
||||
return
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
print_existing_version() {
|
||||
if command -v twitter >/dev/null 2>&1; then
|
||||
echo "twitter-cli is already available: $(twitter --version 2>/dev/null || echo 'version unknown')"
|
||||
fi
|
||||
}
|
||||
|
||||
INSTALLER="$(choose_installer)"
|
||||
|
||||
if [ -z "$INSTALLER" ]; then
|
||||
echo "ERROR: Neither 'uv' nor 'pipx' is installed." >&2
|
||||
echo "Install one of them first, then re-run this script." >&2
|
||||
echo "Examples:" >&2
|
||||
echo " curl -LsSf https://astral.sh/uv/install.sh | sh" >&2
|
||||
echo " python3 -m pip install --user pipx && python3 -m pipx ensurepath" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_existing_version
|
||||
|
||||
case "$INSTALLER" in
|
||||
uv)
|
||||
if [ "$MODE" = "upgrade" ]; then
|
||||
echo "Upgrading twitter-cli with uv..."
|
||||
uv tool upgrade twitter-cli
|
||||
else
|
||||
echo "Installing twitter-cli with uv (with xclienttransaction)..."
|
||||
uv tool install --reinstall twitter-cli --with xclienttransaction
|
||||
fi
|
||||
;;
|
||||
pipx)
|
||||
if [ "$MODE" = "upgrade" ]; then
|
||||
echo "Upgrading twitter-cli with pipx..."
|
||||
pipx upgrade twitter-cli
|
||||
echo "Injecting xclienttransaction..."
|
||||
pipx inject twitter-cli xclienttransaction || true
|
||||
else
|
||||
echo "Installing twitter-cli with pipx..."
|
||||
pipx install --force twitter-cli
|
||||
echo "Injecting xclienttransaction..."
|
||||
pipx inject twitter-cli xclienttransaction || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! command -v twitter >/dev/null 2>&1; then
|
||||
echo "WARN: Installation finished but 'twitter' is not on PATH." >&2
|
||||
echo "Set tools.x_cli_command in config.yaml to the installed binary path." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "twitter-cli ready: $(twitter --version 2>/dev/null || echo 'version unknown')"
|
||||
|
||||
# 動作確認: search --help が正常に実行できるかテスト
|
||||
echo ""
|
||||
echo "Running smoke test (twitter search --help)..."
|
||||
if twitter search --help >/dev/null 2>&1; then
|
||||
echo "Smoke test passed."
|
||||
else
|
||||
echo "WARN: 'twitter search --help' failed. twitter-cli may not work correctly." >&2
|
||||
echo "Try reinstalling: ./scripts/install-twitter-cli.sh" >&2
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Next:"
|
||||
echo " 1. Put cookies into config.yaml if needed (tools.x_auth_token / tools.x_ct0)"
|
||||
echo " 2. Use XSearch / XUserPosts / XPostDetail from general or research"
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* lint-pieces: enforces that no piece YAML uses `COMPLETE` / `ABORT` / `ASK`
|
||||
* as `transition.rules[].next` values. Terminal moves go through the
|
||||
* `complete` tool (Phase 6 design).
|
||||
*
|
||||
* `default_next: COMPLETE` is allowed — that's an engine-internal sentinel
|
||||
* used by context-overflow / ASK-limit / SpawnSubTask-unavailable fallback
|
||||
* paths and is never exposed to the LLM.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/lint-pieces.mjs # lint all pieces/*.yaml
|
||||
* node scripts/lint-pieces.mjs path/x.yaml # lint specific files
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — no violations
|
||||
* 1 — at least one piece uses a banned terminal in rules[].next
|
||||
*
|
||||
* History:
|
||||
* - Phase 6a-1: introduced ALLOWLIST mechanism for the 11 unmigrated pieces
|
||||
* - Phase 6a-2: ALLOWLIST drained to empty as pieces migrated
|
||||
* - Phase 6b: ALLOWLIST mechanism removed; violations are always hard fails
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { parse } from 'yaml';
|
||||
|
||||
const TERMINAL_NEXTS = new Set(['COMPLETE', 'ABORT', 'ASK']);
|
||||
|
||||
function collectPieceFiles(args) {
|
||||
if (args.length > 0) return args;
|
||||
const dir = 'pieces';
|
||||
if (!existsSync(dir)) {
|
||||
console.error(`[lint-pieces] directory not found: ${dir}`);
|
||||
process.exit(2);
|
||||
}
|
||||
return readdirSync(dir).filter((f) => f.endsWith('.yaml')).map((f) => join(dir, f));
|
||||
}
|
||||
|
||||
function findTerminalNexts(piece) {
|
||||
const offenders = [];
|
||||
if (!piece || typeof piece !== 'object') return offenders;
|
||||
const movements = Array.isArray(piece.movements) ? piece.movements : [];
|
||||
for (const movement of movements) {
|
||||
const rules = Array.isArray(movement?.rules) ? movement.rules : [];
|
||||
for (const rule of rules) {
|
||||
if (rule && typeof rule.next === 'string' && TERMINAL_NEXTS.has(rule.next)) {
|
||||
offenders.push({
|
||||
movement: String(movement?.name ?? '<unnamed>'),
|
||||
condition: String(rule.condition ?? ''),
|
||||
next: rule.next,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return offenders;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const files = collectPieceFiles(process.argv.slice(2));
|
||||
const violations = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parse(readFileSync(filePath, 'utf-8'));
|
||||
} catch (e) {
|
||||
console.error(`[lint-pieces] failed to parse ${filePath}: ${e.message}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const offenders = findTerminalNexts(parsed);
|
||||
for (const offender of offenders) {
|
||||
violations.push({ file: filePath, ...offender });
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('\n[lint-pieces] ❌ pieces must not use COMPLETE/ABORT/ASK in rules[].next.');
|
||||
console.error('Use the `complete` tool for terminal moves (status: success | aborted | needs_user_input).');
|
||||
console.error('See docs/plans/2026-05-01-phase-6a-complete-tool.md.\n');
|
||||
for (const v of violations) {
|
||||
console.error(` ${v.file} → movement="${v.movement}" rule.next="${v.next}" condition="${v.condition}"`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`[lint-pieces] ✓ ${files.length} piece(s) checked, no violations`);
|
||||
}
|
||||
|
||||
main();
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# migrate-config.sh — convert a legacy (v1) config.yaml to the v2 layout.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/migrate-config.sh # in-place rewrite of ./config.yaml
|
||||
# scripts/migrate-config.sh --dry-run # print result to stdout + diff to stderr
|
||||
# scripts/migrate-config.sh --config /path.yaml # operate on a different file
|
||||
#
|
||||
# The script delegates to the TypeScript CLI compiled at
|
||||
# dist/scripts/migrate-config.js. If that file doesn't exist yet, we try
|
||||
# the tsx runtime as a fallback (matches `npm run dev`'s approach), so
|
||||
# the script works even before `npm run build` has run once.
|
||||
#
|
||||
# Exit codes mirror the Node CLI (see src/scripts/migrate-config.ts).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
DIST_ENTRY="$PROJECT_DIR/dist/scripts/migrate-config.js"
|
||||
TS_ENTRY="$PROJECT_DIR/src/scripts/migrate-config.ts"
|
||||
|
||||
if [[ -f "$DIST_ENTRY" ]]; then
|
||||
exec node "$DIST_ENTRY" "$@"
|
||||
fi
|
||||
|
||||
# Fallback path for pre-build environments. Prefer tsx > ts-node because
|
||||
# the rest of the repo's npm scripts use tsx (see package.json:bench).
|
||||
if command -v npx >/dev/null 2>&1; then
|
||||
if [[ -f "$PROJECT_DIR/node_modules/tsx/dist/cli.mjs" ]] || npx --no-install tsx --version >/dev/null 2>&1; then
|
||||
exec npx --no-install tsx "$TS_ENTRY" "$@"
|
||||
fi
|
||||
if [[ -f "$PROJECT_DIR/node_modules/ts-node/dist/bin.js" ]]; then
|
||||
exec node --loader ts-node/esm "$TS_ENTRY" "$@"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "error: neither dist/scripts/migrate-config.js nor a TS runtime (tsx/ts-node) is available." >&2
|
||||
echo " Run 'npm run build' first, or install tsx (npm i -D tsx)." >&2
|
||||
exit 1
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-bake the Python packages the agent Bash sandbox needs.
|
||||
#
|
||||
# These are installed into the SYSTEM python's site-packages, because that is
|
||||
# what bwrap read-only bind-mounts (/usr) into every Bash sandbox. A `--user`
|
||||
# install (~/.local) is NOT visible inside the sandbox (/home is not mounted),
|
||||
# so it must be a system install.
|
||||
#
|
||||
# Idempotent and safe to re-run. Non-fatal by default (prints a warning and
|
||||
# exits 0 so it does not break a build); pass --strict to fail on error.
|
||||
#
|
||||
# See docs/operations/bash-sandbox-provisioning.md.
|
||||
set -uo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REQ="$PROJECT_DIR/runtime/python-requirements.txt"
|
||||
STRICT=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--strict) STRICT=1 ;;
|
||||
-h|--help) echo "Usage: $0 [--strict]"; exit 0 ;;
|
||||
*) echo "Unknown option: $arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
soft_fail() {
|
||||
echo "[prebake-python] $1" >&2
|
||||
if [ "$STRICT" -eq 1 ]; then exit 1; fi
|
||||
echo "[prebake-python] continuing (non-strict); Bash-tool python packages may be unavailable." >&2
|
||||
exit 0
|
||||
}
|
||||
|
||||
[ -f "$REQ" ] || soft_fail "requirements file not found: $REQ"
|
||||
|
||||
if command -v pip3 >/dev/null 2>&1; then PIP="pip3"
|
||||
elif command -v pip >/dev/null 2>&1; then PIP="pip"
|
||||
elif command -v python3 >/dev/null 2>&1; then PIP="python3 -m pip"
|
||||
else soft_fail "no pip / python3 found — skipping python pre-bake"; fi
|
||||
|
||||
echo "[prebake-python] installing $REQ into the system python via: $PIP"
|
||||
|
||||
# PEP668 distros (Debian/Ubuntu/alpine) mark the system python "externally
|
||||
# managed"; --break-system-packages is required there. Older pip rejects that
|
||||
# flag, so retry without it.
|
||||
if $PIP install --break-system-packages -r "$REQ"; then
|
||||
:
|
||||
elif $PIP install -r "$REQ"; then
|
||||
:
|
||||
else
|
||||
soft_fail "pip install failed. If this is a permission error, retry as: sudo $PIP install --break-system-packages -r runtime/python-requirements.txt"
|
||||
fi
|
||||
|
||||
echo "[prebake-python] done. Verifying imports..."
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - <<'PYEOF' || echo "[prebake-python] WARNING: some packages failed to import (see above)." >&2
|
||||
import importlib, sys
|
||||
# import-name : pip-name (for the warning message)
|
||||
mods = {
|
||||
"pypdf": "pypdf", "fitz": "pymupdf", "pdfplumber": "pdfplumber",
|
||||
"docx": "python-docx", "pptx": "python-pptx", "openpyxl": "openpyxl",
|
||||
"xlsxwriter": "xlsxwriter", "xlrd": "xlrd", "odf": "odfpy", "striprtf": "striprtf",
|
||||
"bs4": "beautifulsoup4", "lxml": "lxml", "markdownify": "markdownify",
|
||||
"markdown": "markdown", "numpy": "numpy", "pandas": "pandas", "tabulate": "tabulate",
|
||||
"dateutil": "python-dateutil", "matplotlib": "matplotlib", "PIL": "Pillow",
|
||||
"charset_normalizer": "charset-normalizer", "yaml": "PyYAML",
|
||||
}
|
||||
missing = []
|
||||
for mod, pkg in mods.items():
|
||||
try:
|
||||
importlib.import_module(mod)
|
||||
except Exception:
|
||||
missing.append(f"{pkg} (import {mod})")
|
||||
if missing:
|
||||
print("[prebake-python] MISSING: " + ", ".join(missing), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"[prebake-python] all {len(mods)} packages import OK.")
|
||||
PYEOF
|
||||
fi
|
||||
Executable
+133
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# ミドルウェア・ランタイム依存の準備スクリプト
|
||||
#
|
||||
# npm install 後や依存バージョン更新後に実行して、
|
||||
# npm パッケージ以外のランタイム依存が揃っていることを確認する。
|
||||
#
|
||||
# ./scripts/prepare.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
OK=0
|
||||
WARN=0
|
||||
FAIL=0
|
||||
|
||||
pass() { echo " ✓ $1"; OK=$((OK + 1)); }
|
||||
warn() { echo " △ $1"; WARN=$((WARN + 1)); }
|
||||
fail() { echo " ✗ $1"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
echo "=== ランタイム依存チェック ==="
|
||||
echo ""
|
||||
|
||||
# -------------------------------------------------
|
||||
# 1. Playwright ブラウザバイナリ
|
||||
# -------------------------------------------------
|
||||
echo "[1/4] Playwright ブラウザ..."
|
||||
|
||||
# playwright が要求する chromium のパスを取得
|
||||
EXPECTED_PATH=$(node -e "
|
||||
try {
|
||||
const { chromium } = require('playwright');
|
||||
console.log(chromium.executablePath());
|
||||
} catch { process.exit(1); }
|
||||
" 2>/dev/null) || EXPECTED_PATH=""
|
||||
|
||||
if [ -n "$EXPECTED_PATH" ] && [ -x "$EXPECTED_PATH" ]; then
|
||||
pass "chromium: $EXPECTED_PATH"
|
||||
else
|
||||
echo " → ブラウザバイナリが見つかりません。インストール中..."
|
||||
if npx playwright install chromium 2>&1 | tail -3; then
|
||||
pass "chromium インストール完了"
|
||||
else
|
||||
fail "chromium のインストールに失敗しました。手動で 'npx playwright install chromium' を実行してください"
|
||||
fi
|
||||
fi
|
||||
|
||||
# システム共有ライブラリ(libnspr4, libnss3, libatk 等)の確認・インストール
|
||||
# playwright install-deps は不足ライブラリを apt 等でインストールする(root 権限が必要)
|
||||
if [ -n "$EXPECTED_PATH" ] && [ -x "$EXPECTED_PATH" ]; then
|
||||
# ldd でリンク切れを簡易チェック
|
||||
MISSING_LIBS=$(ldd "$EXPECTED_PATH" 2>/dev/null | grep "not found" || true)
|
||||
if [ -n "$MISSING_LIBS" ]; then
|
||||
echo " → 共有ライブラリが不足しています。インストール中..."
|
||||
echo "$MISSING_LIBS" | head -5 | sed 's/^/ /'
|
||||
if npx playwright install-deps chromium 2>&1 | tail -3; then
|
||||
pass "システム依存ライブラリ インストール完了"
|
||||
else
|
||||
warn "システム依存ライブラリのインストールに失敗しました(root 権限が必要です)。手動で 'sudo npx playwright install-deps chromium' を実行してください"
|
||||
fi
|
||||
else
|
||||
pass "システム依存ライブラリ: OK"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# -------------------------------------------------
|
||||
# 2. noVNC 依存(オプション)
|
||||
# -------------------------------------------------
|
||||
echo "[2/4] noVNC 依存(オプション)..."
|
||||
|
||||
NOVNC_DEPS=(Xvfb x11vnc websockify)
|
||||
NOVNC_MISSING=()
|
||||
for cmd in "${NOVNC_DEPS[@]}"; do
|
||||
if command -v "$cmd" >/dev/null 2>&1; then
|
||||
pass "$cmd: $(command -v "$cmd")"
|
||||
else
|
||||
NOVNC_MISSING+=("$cmd")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#NOVNC_MISSING[@]} -gt 0 ]; then
|
||||
warn "未インストール: ${NOVNC_MISSING[*]} — noVNC CAPTCHA 解決を使う場合は ./scripts/setup-novnc.sh を実行してください"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# -------------------------------------------------
|
||||
# 3. better-sqlite3 ネイティブモジュール
|
||||
# -------------------------------------------------
|
||||
echo "[3/4] ネイティブモジュール..."
|
||||
|
||||
if node -e "require('better-sqlite3')" 2>/dev/null; then
|
||||
pass "better-sqlite3: ロード OK"
|
||||
else
|
||||
fail "better-sqlite3 のロードに失敗しました。'npm rebuild better-sqlite3' を試してください"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# -------------------------------------------------
|
||||
# 4. 日本語フォント(AnnotateImage のテキスト描画用)
|
||||
# -------------------------------------------------
|
||||
echo "[4/4] 日本語フォント(オプション)..."
|
||||
|
||||
if command -v fc-list >/dev/null 2>&1; then
|
||||
if fc-list :lang=ja 2>/dev/null | grep -q .; then
|
||||
pass "日本語フォント: 利用可能"
|
||||
else
|
||||
warn "日本語フォント未検出 — AnnotateImage のテキスト描画が □□□ になる可能性があります。'sudo apt install fonts-noto-cjk' を実行してください"
|
||||
fi
|
||||
else
|
||||
warn "fc-list コマンドなし — フォント検出をスキップしました"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# -------------------------------------------------
|
||||
# サマリー
|
||||
# -------------------------------------------------
|
||||
echo "=== 結果: ✓ $OK △ $WARN ✗ $FAIL ==="
|
||||
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "エラーがあります。上記の指示に従って修正してください。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "準備完了です。"
|
||||
Executable
+126
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
PID_FILE="$PROJECT_DIR/.server.pid"
|
||||
LOG_FILE="$PROJECT_DIR/logs/server.log"
|
||||
PORT="${PORT:-9876}"
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 {start|stop|restart|status|logs}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
is_running() {
|
||||
if [[ -f "$PID_FILE" ]]; then
|
||||
local pid
|
||||
pid=$(cat "$PID_FILE")
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
do_start() {
|
||||
if is_running; then
|
||||
echo "Server already running (PID $(cat "$PID_FILE"))"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
|
||||
echo "Checking runtime dependencies..."
|
||||
"$SCRIPT_DIR/prepare.sh"
|
||||
|
||||
echo "Building..."
|
||||
npm run build --silent 2>&1 | tail -1
|
||||
|
||||
echo "Starting server on port $PORT..."
|
||||
# AAO is a single binary with two modes (dist/main.js dispatches):
|
||||
# AAO_MODE=worker (default) — full orchestrator (DB + bridge API + workers)
|
||||
# AAO_MODE=gateway — OpenAI-compatible LLM gateway only
|
||||
# To launch as a gateway: `AAO_MODE=gateway scripts/server.sh start` and
|
||||
# set `gateway.listen_port` in config.yaml (default 4000). dist/index.js
|
||||
# is preserved as a worker-mode shim for legacy paths.
|
||||
PORT="$PORT" AAO_MODE="${AAO_MODE:-worker}" nohup node dist/main.js >> "$LOG_FILE" 2>&1 &
|
||||
local pid=$!
|
||||
echo "$pid" > "$PID_FILE"
|
||||
|
||||
# Wait briefly and verify it started
|
||||
sleep 2
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
echo "Server started (PID $pid, log: $LOG_FILE)"
|
||||
else
|
||||
rm -f "$PID_FILE"
|
||||
echo "Server failed to start. Check $LOG_FILE"
|
||||
tail -5 "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
do_stop() {
|
||||
if ! is_running; then
|
||||
echo "Server not running"
|
||||
# Also kill any stray process on the port
|
||||
local stray
|
||||
stray=$(lsof -ti:"$PORT" 2>/dev/null || true)
|
||||
if [[ -n "$stray" ]]; then
|
||||
echo "Found stray process on port $PORT (PID $stray), killing..."
|
||||
kill "$stray" 2>/dev/null || true
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pid
|
||||
pid=$(cat "$PID_FILE")
|
||||
echo "Stopping server (PID $pid)..."
|
||||
kill "$pid" 2>/dev/null || true
|
||||
|
||||
# Wait for graceful shutdown
|
||||
for i in {1..10}; do
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
rm -f "$PID_FILE"
|
||||
echo "Server stopped"
|
||||
return 0
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
# Force kill
|
||||
echo "Force killing..."
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
rm -f "$PID_FILE"
|
||||
echo "Server stopped (forced)"
|
||||
}
|
||||
|
||||
do_status() {
|
||||
if is_running; then
|
||||
local pid
|
||||
pid=$(cat "$PID_FILE")
|
||||
echo "Server running (PID $pid, port $PORT)"
|
||||
else
|
||||
echo "Server not running"
|
||||
fi
|
||||
}
|
||||
|
||||
do_logs() {
|
||||
if [[ -f "$LOG_FILE" ]]; then
|
||||
tail -f "$LOG_FILE"
|
||||
else
|
||||
echo "No log file found at $LOG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
start) do_start ;;
|
||||
stop) do_stop ;;
|
||||
restart) do_stop; do_start ;;
|
||||
status) do_status ;;
|
||||
logs) do_logs ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== noVNC CAPTCHA 解決機能セットアップ ==="
|
||||
|
||||
# 1. システム依存パッケージのインストール
|
||||
echo ""
|
||||
echo "[1/5] システムパッケージをインストール中..."
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq x11vnc websockify xvfb
|
||||
|
||||
# 2. 確認
|
||||
echo ""
|
||||
echo "[2/5] インストール確認..."
|
||||
for cmd in Xvfb x11vnc websockify; do
|
||||
if which "$cmd" > /dev/null 2>&1; then
|
||||
echo " ✓ $cmd: $(which "$cmd")"
|
||||
else
|
||||
echo " ✗ $cmd: 見つかりません" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# 3. @novnc/novnc npm パッケージ確認
|
||||
echo ""
|
||||
echo "[3/5] @novnc/novnc パッケージ確認..."
|
||||
if node -e "require.resolve('@novnc/novnc/package.json')" 2>/dev/null; then
|
||||
echo " ✓ @novnc/novnc: インストール済み"
|
||||
else
|
||||
echo " → npm install @novnc/novnc ..."
|
||||
npm install @novnc/novnc
|
||||
fi
|
||||
|
||||
# 4. noVNC スタンドアロン (vnc.html を含む Web 配布物) を vendor/noVNC に配置
|
||||
# npm の @novnc/novnc は lib のみで vnc.html を含まないため、
|
||||
# Browser タブの iframe 用に GitHub から tarball を取得する。
|
||||
echo ""
|
||||
echo "[4/5] noVNC Web 配布物 (vendor/noVNC) 確認..."
|
||||
NOVNC_VERSION="${NOVNC_VERSION:-1.6.0}"
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
VENDOR_DIR="$PROJECT_DIR/vendor"
|
||||
NOVNC_DIR="$VENDOR_DIR/noVNC"
|
||||
if [ -f "$NOVNC_DIR/vnc.html" ]; then
|
||||
echo " ✓ vendor/noVNC/vnc.html: 配置済み"
|
||||
else
|
||||
echo " → noVNC v$NOVNC_VERSION を取得中..."
|
||||
mkdir -p "$VENDOR_DIR"
|
||||
TMP_TGZ="$(mktemp -t novnc.XXXXXX.tar.gz)"
|
||||
trap 'rm -f "$TMP_TGZ"' EXIT
|
||||
if ! curl -fSL "https://github.com/novnc/noVNC/archive/refs/tags/v${NOVNC_VERSION}.tar.gz" -o "$TMP_TGZ"; then
|
||||
echo " ✗ noVNC tarball の取得に失敗しました" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "$NOVNC_DIR"
|
||||
mkdir -p "$NOVNC_DIR"
|
||||
tar -xzf "$TMP_TGZ" -C "$NOVNC_DIR" --strip-components=1
|
||||
rm -f "$TMP_TGZ"
|
||||
trap - EXIT
|
||||
if [ -f "$NOVNC_DIR/vnc.html" ]; then
|
||||
echo " ✓ vendor/noVNC/vnc.html: 配置完了"
|
||||
else
|
||||
echo " ✗ vnc.html が見つかりません。配置に失敗しました" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 5. config.yaml に captcha_solve 設定を追加(まだなければ)
|
||||
echo ""
|
||||
echo "[5/5] config.yaml 確認..."
|
||||
CONFIG="$(cd "$(dirname "$0")/.." && pwd)/config.yaml"
|
||||
if [ -f "$CONFIG" ]; then
|
||||
if grep -q "captcha_solve" "$CONFIG"; then
|
||||
echo " ✓ captcha_solve 設定は既に存在します"
|
||||
else
|
||||
echo " → browser セクションを追加中..."
|
||||
# browser セクションが存在するか確認
|
||||
if grep -q "^browser:" "$CONFIG"; then
|
||||
# 既存 browser セクションに追加
|
||||
sed -i '/^browser:/a\ captcha_solve: novnc\n max_captcha_pages: 5' "$CONFIG"
|
||||
else
|
||||
# browser セクションを新規追加
|
||||
printf '\nbrowser:\n captcha_solve: novnc\n max_captcha_pages: 5\n' >> "$CONFIG"
|
||||
fi
|
||||
echo " ✓ captcha_solve: novnc を追加しました"
|
||||
fi
|
||||
else
|
||||
echo " ⚠ config.yaml が見つかりません。config.yaml.example からコピーして設定してください"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== セットアップ完了 ==="
|
||||
echo ""
|
||||
echo "使い方:"
|
||||
echo " 1. サーバーを再起動: npm start"
|
||||
echo " 2. WebSearch を含むタスクを実行"
|
||||
echo " 3. CAPTCHA 検出時、UI に黄色いバナーが表示されます"
|
||||
echo " 4. 「noVNC で解決する」リンクをクリックしてブラウザ画面に接続"
|
||||
echo " 5. CAPTCHA を手動で解決 → Cookie が保持され次回から再利用されます"
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Gitea adapter を使う場合に Webhook を登録するスクリプト
|
||||
#
|
||||
# 使い方:
|
||||
# ./scripts/setup-repo.sh owner/repo
|
||||
# ./scripts/setup-repo.sh owner/repo http://orchestrator-host:9877
|
||||
#
|
||||
# 環境変数:
|
||||
# GITEA_URL Gitea の URL(デフォルト: config.yaml から取得)
|
||||
# GITEA_API_TOKEN Gitea API トークン(必須)
|
||||
# GITEA_WEBHOOK_SECRET Webhook シークレット(必須)
|
||||
# ORCHESTRATOR_URL Orchestrator の URL(引数でも指定可)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
REPO="${1:-}"
|
||||
ORCHESTRATOR_URL="${2:-${ORCHESTRATOR_URL:-}}"
|
||||
|
||||
if [ -z "$REPO" ]; then
|
||||
echo "Usage: $0 <owner/repo> [orchestrator-url]"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 myorg/myrepo"
|
||||
echo " $0 myorg/myrepo http://10.0.0.10:9877"
|
||||
echo ""
|
||||
echo "Environment variables:"
|
||||
echo " GITEA_API_TOKEN (required) Gitea API token"
|
||||
echo " GITEA_WEBHOOK_SECRET (required) Webhook secret"
|
||||
echo " GITEA_URL Gitea URL (default: from config.yaml)"
|
||||
echo " ORCHESTRATOR_URL Orchestrator URL (default: http://localhost:9876)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# config.yaml から GITEA_URL を取得(未設定の場合)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
if [ -z "${GITEA_URL:-}" ] && [ -f "$PROJECT_DIR/config.yaml" ]; then
|
||||
GITEA_URL=$(grep -E '^\s*url:' "$PROJECT_DIR/config.yaml" | head -1 | sed 's/.*url:\s*//' | tr -d '"' | tr -d "'")
|
||||
fi
|
||||
GITEA_URL="${GITEA_URL:-https://localhost:3000}"
|
||||
|
||||
if [ -z "${GITEA_API_TOKEN:-}" ]; then
|
||||
echo "Error: GITEA_API_TOKEN is not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${GITEA_WEBHOOK_SECRET:-}" ]; then
|
||||
echo "Error: GITEA_WEBHOOK_SECRET is not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ORCHESTRATOR_URL="${ORCHESTRATOR_URL:-http://localhost:9876}"
|
||||
WEBHOOK_URL="${ORCHESTRATOR_URL}/webhook"
|
||||
|
||||
echo "Setting up MAESTRO Gitea webhook..."
|
||||
echo " Gitea: $GITEA_URL"
|
||||
echo " Repository: $REPO"
|
||||
echo " Webhook URL: $WEBHOOK_URL"
|
||||
echo ""
|
||||
|
||||
# 既存の webhook を確認
|
||||
EXISTING=$(curl -sf \
|
||||
-H "Authorization: token ${GITEA_API_TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/hooks" 2>/dev/null || echo "[]")
|
||||
|
||||
# 同じ URL の webhook が既にあるかチェック
|
||||
if echo "$EXISTING" | python3 -c "
|
||||
import sys, json
|
||||
hooks = json.load(sys.stdin)
|
||||
for h in hooks:
|
||||
if h.get('config', {}).get('url', '') == '${WEBHOOK_URL}':
|
||||
print(h['id'])
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
" 2>/dev/null; then
|
||||
echo "Webhook already exists for this URL. Skipping creation."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Webhook 作成
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/hooks" \
|
||||
-d "{
|
||||
\"type\": \"gitea\",
|
||||
\"active\": true,
|
||||
\"events\": [\"issues\", \"issue_comment\"],
|
||||
\"config\": {
|
||||
\"url\": \"${WEBHOOK_URL}\",
|
||||
\"content_type\": \"json\",
|
||||
\"secret\": \"${GITEA_WEBHOOK_SECRET}\"
|
||||
}
|
||||
}")
|
||||
|
||||
HOOK_ID=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])" 2>/dev/null || echo "unknown")
|
||||
echo "Webhook created (ID: ${HOOK_ID})"
|
||||
|
||||
# ヘルスチェック
|
||||
echo ""
|
||||
echo -n "Checking orchestrator health... "
|
||||
if curl -sf "${ORCHESTRATOR_URL}/health" > /dev/null 2>&1; then
|
||||
echo "OK"
|
||||
else
|
||||
echo "WARN: orchestrator not reachable at ${ORCHESTRATOR_URL}"
|
||||
echo " Make sure the orchestrator is running and accessible from Gitea."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done! Create an issue on ${GITEA_URL}/${REPO} to test."
|
||||
echo "If you run in local mode, this script is not needed."
|
||||
Executable
+275
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# MAESTRO 初回セットアップスクリプト
|
||||
#
|
||||
# 新しい環境でゼロから動かすための対話式セットアップ。
|
||||
# クローン済みのディレクトリで実行する:
|
||||
#
|
||||
# git clone https://gitea.example.com/your-org/maestro.git maestro
|
||||
# cd maestro
|
||||
# ./scripts/setup.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
echo "=========================================="
|
||||
echo " MAESTRO セットアップ"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Step 1: 依存関係チェック
|
||||
# -------------------------------------------------------
|
||||
echo "[1/5] 前提条件チェック..."
|
||||
|
||||
MISSING=()
|
||||
command -v node >/dev/null 2>&1 || MISSING+=("node")
|
||||
command -v npm >/dev/null 2>&1 || MISSING+=("npm")
|
||||
command -v curl >/dev/null 2>&1 || MISSING+=("curl")
|
||||
|
||||
if [ ${#MISSING[@]} -gt 0 ]; then
|
||||
echo " ERROR: 以下がインストールされていません: ${MISSING[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NODE_VER=$(node -v | sed 's/v//' | cut -d. -f1)
|
||||
if [ "$NODE_VER" -lt 20 ]; then
|
||||
echo " ERROR: Node.js 20+ が必要です(現在: $(node -v))"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " Node.js $(node -v) ... OK"
|
||||
echo ""
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Step 2: npm install & build
|
||||
# -------------------------------------------------------
|
||||
echo "[2/5] 依存パッケージのインストール & ビルド..."
|
||||
|
||||
./scripts/build-all.sh | tail -5
|
||||
|
||||
echo " ビルド完了"
|
||||
echo ""
|
||||
|
||||
# Playwright ブラウザのインストール(WebSearch に必要)
|
||||
echo " Playwright ブラウザをインストール中..."
|
||||
if npx playwright install chromium 2>/dev/null; then
|
||||
echo " Playwright chromium インストール完了"
|
||||
else
|
||||
echo " WARN: Playwright のインストールに失敗しました。WebSearch は SearXNG フォールバックで動作します。"
|
||||
echo " 後から 'npx playwright install chromium' で手動インストールできます。"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Step 3: config.yaml 生成
|
||||
# -------------------------------------------------------
|
||||
echo "[3/5] 設定ファイル (config.yaml) の作成..."
|
||||
|
||||
DEFAULT_OLLAMA_URL="http://localhost:11434/v1"
|
||||
DEFAULT_MODEL_NAME="qwen3:8b"
|
||||
DEFAULT_ALLOWED_USER="*"
|
||||
DEFAULT_MODE="local"
|
||||
|
||||
if [ -f "config.yaml" ]; then
|
||||
EXISTING_MODE=$(grep -E '^\s*integration_mode:' config.yaml | head -1 | sed 's/.*integration_mode:\s*//' | tr -d '"' | tr -d "'" || true)
|
||||
EXISTING_OLLAMA_URL=$(grep -E '^\s*base_url:' config.yaml | head -1 | sed 's/.*base_url:\s*//' | tr -d '"' | tr -d "'" || true)
|
||||
EXISTING_MODEL_NAME=$(grep -E '^\s*model:' config.yaml | head -1 | sed 's/.*model:\s*//' | tr -d '"' | tr -d "'" || true)
|
||||
EXISTING_ALLOWED_USER=$(grep -E '^\s*allowed_users:' config.yaml | head -1 | sed 's/.*\["\?//' | sed 's/"\?\].*//' || true)
|
||||
|
||||
[ -n "${EXISTING_MODE}" ] && DEFAULT_MODE="${EXISTING_MODE}"
|
||||
[ -n "${EXISTING_OLLAMA_URL}" ] && DEFAULT_OLLAMA_URL="${EXISTING_OLLAMA_URL}"
|
||||
[ -n "${EXISTING_MODEL_NAME}" ] && DEFAULT_MODEL_NAME="${EXISTING_MODEL_NAME}"
|
||||
[ -n "${EXISTING_ALLOWED_USER}" ] && DEFAULT_ALLOWED_USER="${EXISTING_ALLOWED_USER}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
read -rp " 起動モード (local / hybrid / gitea) [${DEFAULT_MODE}]: " INTEGRATION_MODE
|
||||
read -rp " Ollama の URL (例: http://gpu-server:11434/v1) [${DEFAULT_OLLAMA_URL}]: " OLLAMA_URL
|
||||
read -rp " 使用する LLM モデル名 (例: qwen3:8b) [${DEFAULT_MODEL_NAME}]: " MODEL_NAME
|
||||
read -rp " Local/Gitea で受け付けるユーザー名 [${DEFAULT_ALLOWED_USER}]: " ALLOWED_USER
|
||||
|
||||
INTEGRATION_MODE="${INTEGRATION_MODE:-$DEFAULT_MODE}"
|
||||
OLLAMA_URL="${OLLAMA_URL:-$DEFAULT_OLLAMA_URL}"
|
||||
MODEL_NAME="${MODEL_NAME:-$DEFAULT_MODEL_NAME}"
|
||||
ALLOWED_USER="${ALLOWED_USER:-$DEFAULT_ALLOWED_USER}"
|
||||
|
||||
GITEA_URL=""
|
||||
if [ "$INTEGRATION_MODE" = "hybrid" ] || [ "$INTEGRATION_MODE" = "gitea" ]; then
|
||||
EXISTING_GITEA_URL=$(grep -E '^\s*url:' config.yaml 2>/dev/null | head -1 | sed 's/.*url:\s*//' | tr -d '"' | tr -d "'" || true)
|
||||
DEFAULT_GITEA_URL="${EXISTING_GITEA_URL:-https://your-gitea.example.com}"
|
||||
read -rp " Gitea の URL (例: https://gitea.example.com) [${DEFAULT_GITEA_URL}]: " GITEA_URL
|
||||
GITEA_URL="${GITEA_URL:-$DEFAULT_GITEA_URL}"
|
||||
fi
|
||||
|
||||
if [ -f "config.yaml" ]; then
|
||||
cp config.yaml config.yaml.bak
|
||||
echo " 既存の config.yaml を config.yaml.bak にバックアップしました"
|
||||
fi
|
||||
|
||||
if [ ! -f "config.yaml.example" ]; then
|
||||
echo " ERROR: config.yaml.example が見つかりません"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp config.yaml.example config.yaml
|
||||
sed -i "s|^\(\s*integration_mode:\s*\).*|\1${INTEGRATION_MODE}|" config.yaml
|
||||
sed -i "s|^\(\s*model:\s*\).*|\1${MODEL_NAME}|" config.yaml
|
||||
sed -i "s|^\(\s*base_url:\s*\).*|\1${OLLAMA_URL}|" config.yaml
|
||||
sed -i "s|^\(allowed_users:\s*\).*|\1[\"${ALLOWED_USER}\"]|" config.yaml
|
||||
if [ -n "${GITEA_URL}" ]; then
|
||||
python3 - <<PY
|
||||
from pathlib import Path
|
||||
path = Path("config.yaml")
|
||||
text = path.read_text()
|
||||
needle = "# gitea:\n# url: https://your-gitea.example.com\n# token_env: GITEA_API_TOKEN\n# webhook_secret_env: GITEA_WEBHOOK_SECRET\n"
|
||||
replacement = "gitea:\n url: ${GITEA_URL}\n token_env: GITEA_API_TOKEN\n webhook_secret_env: GITEA_WEBHOOK_SECRET\n"
|
||||
path.write_text(text.replace(needle, replacement))
|
||||
PY
|
||||
fi
|
||||
|
||||
echo " config.yaml を生成/更新しました"
|
||||
echo ""
|
||||
|
||||
echo " DB / workspace ディレクトリを準備しています..."
|
||||
DB_PATH=$(grep -E '^\s*db_path:' config.yaml | head -1 | sed 's/.*db_path:\s*//' | tr -d '"' | tr -d "'" || true)
|
||||
WORKTREE_DIR=$(grep -E '^\s*worktree_dir:' config.yaml | head -1 | sed 's/.*worktree_dir:\s*//' | tr -d '"' | tr -d "'" || true)
|
||||
|
||||
if [ -n "${DB_PATH}" ]; then
|
||||
DB_DIR=$(dirname "${DB_PATH}")
|
||||
mkdir -p "${DB_DIR}"
|
||||
echo " DB directory: ${DB_DIR}"
|
||||
else
|
||||
echo " db_path は未設定(既定の data/maestro.db が使われます)"
|
||||
mkdir -p data
|
||||
echo " fallback DB directory: $(pwd)/data"
|
||||
fi
|
||||
|
||||
if [ -n "${WORKTREE_DIR}" ]; then
|
||||
mkdir -p "${WORKTREE_DIR}"
|
||||
echo " worktree_dir: ${WORKTREE_DIR}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Step 4: 環境変数の確認
|
||||
# -------------------------------------------------------
|
||||
echo "[4/5] 環境変数の確認..."
|
||||
|
||||
if [ "$INTEGRATION_MODE" = "hybrid" ] || [ "$INTEGRATION_MODE" = "gitea" ]; then
|
||||
if [ -z "${GITEA_API_TOKEN:-}" ]; then
|
||||
echo ""
|
||||
echo " GITEA_API_TOKEN が未設定です。"
|
||||
echo " Gitea の設定 → アプリケーション → トークン生成 で作成してください。"
|
||||
read -rp " Gitea API トークンを入力: " GITEA_API_TOKEN
|
||||
export GITEA_API_TOKEN
|
||||
fi
|
||||
|
||||
if [ -z "${GITEA_WEBHOOK_SECRET:-}" ]; then
|
||||
# ランダム生成
|
||||
GITEA_WEBHOOK_SECRET=$(head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
export GITEA_WEBHOOK_SECRET
|
||||
echo " Webhook シークレットを自動生成しました: ${GITEA_WEBHOOK_SECRET}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " 以下を .env やシェルの起動スクリプトに保存してください:"
|
||||
echo ""
|
||||
echo " export GITEA_API_TOKEN=\"${GITEA_API_TOKEN}\""
|
||||
echo " export GITEA_WEBHOOK_SECRET=\"${GITEA_WEBHOOK_SECRET}\""
|
||||
echo ""
|
||||
else
|
||||
echo " local モードのため Gitea 用環境変数は不要です。"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------
|
||||
# Step 5: Webhook 登録
|
||||
# -------------------------------------------------------
|
||||
echo "[5/5] Webhook の登録..."
|
||||
echo ""
|
||||
if [ "$INTEGRATION_MODE" = "local" ]; then
|
||||
echo " local モードのため Webhook 登録はスキップします。"
|
||||
TARGET_REPO=""
|
||||
else
|
||||
read -rp " 監視するリポジトリ (例: myorg/myrepo、スキップは空Enter): " TARGET_REPO
|
||||
fi
|
||||
|
||||
if [ -n "$TARGET_REPO" ]; then
|
||||
read -rp " Orchestrator の外部URL (例: http://10.0.0.10:9876): " ORCH_URL
|
||||
ORCH_URL="${ORCH_URL:-http://localhost:9876}"
|
||||
|
||||
# UI の repo プルダウンに出すため ui_repos へ登録
|
||||
if grep -q '^ui_repos:' config.yaml; then
|
||||
if grep -q "- ${TARGET_REPO}" config.yaml; then
|
||||
echo " ui_repos に ${TARGET_REPO} は既に登録済みです"
|
||||
else
|
||||
echo " NOTE: ui_repos セクションは既存のため自動追記はスキップしました。"
|
||||
echo " config.yaml の ui_repos に '${TARGET_REPO}' を追加してください。"
|
||||
fi
|
||||
else
|
||||
cat >> config.yaml <<YAML
|
||||
|
||||
ui_repos:
|
||||
- ${TARGET_REPO}
|
||||
YAML
|
||||
echo " ui_repos に ${TARGET_REPO} を追加しました"
|
||||
fi
|
||||
|
||||
GITEA_URL=$(grep -E '^\s*url:' config.yaml | head -1 | sed 's/.*url:\s*//' | tr -d '"' | tr -d "'")
|
||||
|
||||
WEBHOOK_URL="${ORCH_URL}/webhook"
|
||||
|
||||
echo ""
|
||||
echo " Webhook を登録中..."
|
||||
echo " Gitea: ${GITEA_URL}"
|
||||
echo " Repository: ${TARGET_REPO}"
|
||||
echo " Webhook URL: ${WEBHOOK_URL}"
|
||||
|
||||
RESPONSE=$(curl -sf -X POST \
|
||||
-H "Authorization: token ${GITEA_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${GITEA_URL}/api/v1/repos/${TARGET_REPO}/hooks" \
|
||||
-d "{
|
||||
\"type\": \"gitea\",
|
||||
\"active\": true,
|
||||
\"events\": [\"issues\", \"issue_comment\"],
|
||||
\"config\": {
|
||||
\"url\": \"${WEBHOOK_URL}\",
|
||||
\"content_type\": \"json\",
|
||||
\"secret\": \"${GITEA_WEBHOOK_SECRET}\"
|
||||
}
|
||||
}" 2>&1) || true
|
||||
|
||||
if echo "$RESPONSE" | python3 -c "import sys,json; print(' Webhook ID:', json.load(sys.stdin)['id'])" 2>/dev/null; then
|
||||
echo " 登録完了"
|
||||
else
|
||||
echo " WARN: Webhook の登録に失敗しました。手動で設定してください。"
|
||||
echo " エラー: ${RESPONSE}"
|
||||
fi
|
||||
else
|
||||
echo " スキップ。後から ./scripts/setup-repo.sh で追加できます。"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " セットアップ完了!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "起動コマンド:"
|
||||
echo ""
|
||||
if [ "$INTEGRATION_MODE" = "hybrid" ] || [ "$INTEGRATION_MODE" = "gitea" ]; then
|
||||
echo " export GITEA_API_TOKEN=\"${GITEA_API_TOKEN}\""
|
||||
echo " export GITEA_WEBHOOK_SECRET=\"${GITEA_WEBHOOK_SECRET}\""
|
||||
fi
|
||||
echo " npm start"
|
||||
echo ""
|
||||
echo "管理ダッシュボード: http://localhost:9876/ui/"
|
||||
echo ""
|
||||
if [ "$INTEGRATION_MODE" != "local" ]; then
|
||||
echo "追加リポジトリの監視:"
|
||||
echo " ./scripts/setup-repo.sh owner/repo http://this-machine:9876"
|
||||
fi
|
||||
echo ""
|
||||
@@ -0,0 +1,91 @@
|
||||
// scripts/validate-help-docs.mjs
|
||||
// Validates ui/src/content/help/*.md frontmatter + structural invariants.
|
||||
// Exits non-zero on any error. Run before `vite build`.
|
||||
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const HELP_DIR = resolve(__dirname, '../ui/src/content/help');
|
||||
// `yaml` lives in ui/node_modules (the build runs `node ../scripts/...` from ui/).
|
||||
// Resolve it relative to ui/ so this script works regardless of cwd.
|
||||
let parseYaml;
|
||||
try {
|
||||
const require = createRequire(pathToFileURL(resolve(__dirname, '../ui/package.json')));
|
||||
({ parse: parseYaml } = await import(pathToFileURL(require.resolve('yaml')).href));
|
||||
} catch {
|
||||
console.error("help-docs validation: cannot load 'yaml' from ui/node_modules — run `npm --prefix ui install`");
|
||||
process.exit(1);
|
||||
}
|
||||
const CATEGORIES = ['basic', 'advanced', 'admin'];
|
||||
|
||||
// KEEP IN SYNC with ui/src/lib/help.ts slugify()
|
||||
function slugify(text) {
|
||||
const s = text.replace(/<[^>]*>/g, '').trim().toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '');
|
||||
return s || 'section';
|
||||
}
|
||||
|
||||
function splitFrontmatter(raw) {
|
||||
const n = raw.replace(/^/, '').replace(/\r\n/g, '\n');
|
||||
const m = /^---\n([\s\S]*?)\n---\n?/.exec(n);
|
||||
if (!m) return { frontmatter: '', body: n };
|
||||
return { frontmatter: m[1], body: n.slice(m[0].length) };
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
const ids = new Map();
|
||||
const orders = new Map();
|
||||
|
||||
const files = readdirSync(HELP_DIR).filter((f) => f.endsWith('.md')).sort();
|
||||
if (files.length === 0) errors.push('no .md files found in help dir');
|
||||
|
||||
for (const file of files) {
|
||||
const raw = readFileSync(join(HELP_DIR, file), 'utf8');
|
||||
const { frontmatter, body } = splitFrontmatter(raw);
|
||||
if (!frontmatter) { errors.push(`${file}: missing frontmatter block`); continue; }
|
||||
let data;
|
||||
try { data = parseYaml(frontmatter); } catch (e) { errors.push(`${file}: YAML error ${e.message}`); continue; }
|
||||
|
||||
if (typeof data?.id !== 'string' || !data.id.trim()) errors.push(`${file}: 'id' required`);
|
||||
if (typeof data?.title !== 'string' || !data.title.trim()) errors.push(`${file}: 'title' required`);
|
||||
if (!CATEGORIES.includes(data?.category)) errors.push(`${file}: 'category' must be ${CATEGORIES.join('|')}`);
|
||||
if (typeof data?.order !== 'number' || !Number.isFinite(data.order)) errors.push(`${file}: 'order' must be a number`);
|
||||
if (data?.keywords != null && (!Array.isArray(data.keywords) || data.keywords.some((k) => typeof k !== 'string')))
|
||||
errors.push(`${file}: 'keywords' must be a string array`);
|
||||
|
||||
if (typeof data?.id === 'string') {
|
||||
if (ids.has(data.id)) errors.push(`${file}: duplicate id '${data.id}' (also ${ids.get(data.id)})`);
|
||||
else ids.set(data.id, file);
|
||||
}
|
||||
if (typeof data?.order === 'number') {
|
||||
if (orders.has(data.order)) errors.push(`${file}: duplicate order ${data.order} (also ${orders.get(data.order)})`);
|
||||
else orders.set(data.order, file);
|
||||
}
|
||||
|
||||
// Duplicate heading slugs within a file (anchor links would be ambiguous).
|
||||
const slugCounts = new Map();
|
||||
for (const line of body.split('\n')) {
|
||||
const h = /^(#{2,3})\s+(.*)$/.exec(line);
|
||||
if (!h) continue;
|
||||
const slug = slugify(h[2]);
|
||||
slugCounts.set(slug, (slugCounts.get(slug) ?? 0) + 1);
|
||||
}
|
||||
for (const [slug, count] of slugCounts) {
|
||||
if (count > 1) errors.push(`${file}: ${count} headings collide on slug '${slug}' (anchor links ambiguous)`);
|
||||
}
|
||||
|
||||
// Relative markdown links that point at local files must resolve.
|
||||
for (const m of body.matchAll(/\]\((\.{1,2}\/[^)]+)\)/g)) {
|
||||
const target = resolve(HELP_DIR, m[1].split('#')[0]);
|
||||
if (!existsSync(target)) errors.push(`${file}: broken relative link ${m[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
console.error('help-docs validation FAILED:');
|
||||
for (const e of errors) console.error(' - ' + e);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`help-docs validation OK (${files.length} files)`);
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Rotate the VAPID keypair used by Web Push notifications.
|
||||
*
|
||||
* Behavior:
|
||||
* 1. Read the current key from `data/secrets/vapid.json` (or wherever
|
||||
* `notifications.push.vapid_current_path` points, default
|
||||
* `data/secrets/vapid.json`).
|
||||
* 2. Move it to the history directory (default `data/secrets/vapid-history/`).
|
||||
* 3. Generate a fresh keypair and write it as the new current.
|
||||
* 4. Existing subscriptions in the DB are NOT modified — they retain the
|
||||
* old `vapid_key_id` and will still receive pushes (we read from history
|
||||
* based on that id). To force re-subscribe, delete the history file
|
||||
* manually after a grace period; users will see 401 on next push and
|
||||
* can re-subscribe from Settings.
|
||||
*
|
||||
* Usage:
|
||||
* npm run vapid-rotate
|
||||
*/
|
||||
import { join } from 'path';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import { VapidKeyStore } from '../src/vapid-store.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = await loadConfig();
|
||||
const pushCfg = config.notifications?.push;
|
||||
const subject =
|
||||
pushCfg?.vapidSubject ?? 'https://maestro.example.com/';
|
||||
const currentPath = join(process.cwd(), 'data/secrets/vapid.json');
|
||||
const historyDir =
|
||||
pushCfg?.vapidHistoryDir ?? join(process.cwd(), 'data/secrets/vapid-history');
|
||||
|
||||
const store = new VapidKeyStore(currentPath, historyDir);
|
||||
const newKey = store.rotate(subject);
|
||||
|
||||
console.log('VAPID rotation complete.');
|
||||
console.log(` new keyId: ${newKey.keyId}`);
|
||||
console.log(` public key: ${newKey.publicKey}`);
|
||||
console.log(` history dir: ${historyDir}`);
|
||||
console.log('');
|
||||
console.log('Existing subscriptions remain valid via the history key.');
|
||||
console.log('To force re-subscribe, remove the history file after the grace period');
|
||||
console.log('and notify users via Settings → Notifications.');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('VAPID rotation failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user