127 lines
4.4 KiB
Bash
Executable File
127 lines
4.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
set -euo pipefail
|
|
|
|
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
workspace_dir="$(cd -- "$script_dir/../.." && pwd)"
|
|
backend_dir="$workspace_dir/backend"
|
|
snapshot_path="$workspace_dir/frontend/src/shared/api/spec/openapi.json"
|
|
|
|
if [[ ! -f "$snapshot_path" ]]; then
|
|
echo "Missing committed OpenAPI snapshot: $snapshot_path" >&2
|
|
exit 1
|
|
fi
|
|
|
|
node_major="$(node -p "process.versions.node.split('.')[0]")"
|
|
if [[ "$node_major" != "22" ]]; then
|
|
echo "Node 22 is required; found $(node --version)." >&2
|
|
exit 1
|
|
fi
|
|
|
|
temporary_dir="$(mktemp -d "${TMPDIR:-/tmp}/wealth-ledger-runtime-openapi.XXXXXX")"
|
|
backend_pid=""
|
|
|
|
cleanup() {
|
|
if [[ -n "$backend_pid" ]] && kill -0 "$backend_pid" 2>/dev/null; then
|
|
kill "$backend_pid" 2>/dev/null || true
|
|
wait "$backend_pid" 2>/dev/null || true
|
|
fi
|
|
rm -rf "$temporary_dir"
|
|
}
|
|
trap cleanup EXIT INT TERM
|
|
|
|
port="$(node -e "const server = require('node:net').createServer(); server.listen(0, '127.0.0.1', () => { console.log(server.address().port); server.close(); });")"
|
|
runtime_path="$temporary_dir/runtime.json"
|
|
canonical_runtime_path="$temporary_dir/runtime.canonical.json"
|
|
canonical_snapshot_path="$temporary_dir/snapshot.canonical.json"
|
|
backend_log="$temporary_dir/backend.log"
|
|
compile_log="$temporary_dir/compile.log"
|
|
|
|
if ! (
|
|
cd "$backend_dir"
|
|
./node_modules/.bin/tsc --project tsconfig.build.json --outDir "$temporary_dir/dist"
|
|
) >"$compile_log" 2>&1; then
|
|
echo "Temporary backend compilation failed:" >&2
|
|
sed -n '1,160p' "$compile_log" >&2
|
|
exit 1
|
|
fi
|
|
ln -s "$backend_dir/node_modules" "$temporary_dir/dist/node_modules"
|
|
|
|
(
|
|
cd "$backend_dir"
|
|
NODE_ENV=development \
|
|
PORT="$port" \
|
|
DATABASE_URL="file:$temporary_dir/runtime.db" \
|
|
FRONTEND_ORIGIN="http://localhost:5173" \
|
|
JWT_SECRET="runtime-openapi-check-secret-at-least-32" \
|
|
TOKEN_ENCRYPTION_KEY="MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" \
|
|
TBANK_GRPC_ENDPOINT="127.0.0.1:1" \
|
|
TBANK_RATE_LIMIT_PER_SECOND=5 \
|
|
LOG_LEVEL=error \
|
|
node "$temporary_dir/dist/src/main.js"
|
|
) >"$backend_log" 2>&1 &
|
|
backend_pid="$!"
|
|
|
|
for _ in $(seq 1 60); do
|
|
if node -e "fetch(process.argv[1]).then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))" "http://127.0.0.1:$port/api/docs-json"; then
|
|
break
|
|
fi
|
|
if ! kill -0 "$backend_pid" 2>/dev/null; then
|
|
echo "Backend stopped before exposing /api/docs-json:" >&2
|
|
sed -n '1,160p' "$backend_log" >&2
|
|
exit 1
|
|
fi
|
|
sleep 0.25
|
|
done
|
|
|
|
if ! node -e 'fetch(process.argv[1]).then(async (response) => { if (!response.ok) throw new Error(`${response.status} ${response.statusText}`); require("node:fs").writeFileSync(process.argv[2], await response.text()); }).catch((error) => { console.error(`OpenAPI fetch failed: ${error.message}`); process.exit(1); })' "http://127.0.0.1:$port/api/docs-json" "$runtime_path"; then
|
|
echo "Backend did not expose /api/docs-json within 15 seconds:" >&2
|
|
sed -n '1,160p' "$backend_log" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if node - "$runtime_path" "$snapshot_path" "$canonical_runtime_path" "$canonical_snapshot_path" <<'NODE'
|
|
const { createHash } = require('node:crypto');
|
|
const { readFileSync, writeFileSync } = require('node:fs');
|
|
|
|
function canonicalize(value) {
|
|
if (Array.isArray(value)) return value.map(canonicalize);
|
|
if (value && typeof value === 'object') {
|
|
return Object.fromEntries(
|
|
Object.keys(value)
|
|
.sort()
|
|
.map((key) => [key, canonicalize(value[key])]),
|
|
);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
const [runtimePath, snapshotPath, canonicalRuntimePath, canonicalSnapshotPath] =
|
|
process.argv.slice(2);
|
|
const documents = [runtimePath, snapshotPath].map((path) =>
|
|
canonicalize(JSON.parse(readFileSync(path, 'utf8'))),
|
|
);
|
|
const serialized = documents.map((document) => `${JSON.stringify(document, null, 2)}\n`);
|
|
writeFileSync(canonicalRuntimePath, serialized[0]);
|
|
writeFileSync(canonicalSnapshotPath, serialized[1]);
|
|
const hashes = serialized.map((value) =>
|
|
createHash('sha256').update(value).digest('hex'),
|
|
);
|
|
console.log(`runtime SHA-256: ${hashes[0]}`);
|
|
console.log(`snapshot SHA-256: ${hashes[1]}`);
|
|
process.exitCode = hashes[0] === hashes[1] ? 0 : 1;
|
|
NODE
|
|
then
|
|
comparison_status=0
|
|
else
|
|
comparison_status=$?
|
|
fi
|
|
|
|
if [[ "$comparison_status" -ne 0 ]]; then
|
|
echo "Runtime OpenAPI differs from the committed frontend snapshot:" >&2
|
|
diff -u "$canonical_snapshot_path" "$canonical_runtime_path" || true
|
|
exit 1
|
|
fi
|
|
|
|
echo "Runtime OpenAPI matches the committed frontend snapshot."
|