Refactor local emulator setup and enhance GitHub Actions workflow

- Updated the local emulator scripts to streamline command usage, replacing the previous docker-compose setup with a QEMU-based approach for improved performance and simplicity.
- Removed the outdated `docker-compose.yaml` file and adjusted related scripts to utilize QEMU for running the emulator, enhancing clarity and maintainability.
- Enhanced the GitHub Actions workflow to include a smoke test for the emulator, ensuring that services are healthy after startup.
- Updated environment variable handling in the emulator scripts to reflect new port configurations, improving service accessibility.
- Adjusted documentation to reflect the new emulator commands and usage patterns, providing clearer guidance for developers.

These changes improve the usability and reliability of the local emulator setup, facilitating a smoother development experience.
This commit is contained in:
mantrakp04 2026-03-23 15:25:05 -07:00
parent 9d0f7c1acd
commit 77d87fd2dd
8 changed files with 224 additions and 361 deletions

View File

@ -5,6 +5,7 @@ on:
branches:
- main
- dev
pull_request:
workflow_dispatch:
inputs:
publish:
@ -51,6 +52,9 @@ jobs:
EMULATOR_PROVISION_TIMEOUT=6000 \
docker/local-emulator/qemu/build-image.sh ${{ matrix.arch }}
- name: Generate emulator env
run: node docker/local-emulator/generate-env-development.mjs
- name: Start emulator and verify
run: |
chmod +x docker/local-emulator/qemu/run-emulator.sh
@ -64,7 +68,7 @@ jobs:
docker/local-emulator/qemu/run-emulator.sh status
- name: Stop emulator
if: ${{ always() }}
if: always()
run: |
EMULATOR_ARCH=${{ matrix.arch }} \
docker/local-emulator/qemu/run-emulator.sh stop
@ -82,9 +86,71 @@ jobs:
retention-days: 30
compression-level: 0
test:
name: Smoke Test (${{ matrix.arch }})
needs: build
runs-on: ubicloud-standard-8
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
steps:
- uses: actions/checkout@v6
- name: Install QEMU dependencies
run: |
sudo apt-get update
sudo apt-get install -y qemu-system-x86 qemu-utils genisoimage socat
- name: Download built image
uses: actions/download-artifact@v4
with:
name: qemu-emulator-${{ matrix.arch }}
path: docker/local-emulator/qemu/images/
- name: Generate emulator env
run: node docker/local-emulator/generate-env-development.mjs
- name: Start emulator from artifact
run: |
chmod +x docker/local-emulator/qemu/run-emulator.sh docker/local-emulator/qemu/common.sh
EMULATOR_ARCH=${{ matrix.arch }} \
EMULATOR_READY_TIMEOUT=600 \
docker/local-emulator/qemu/run-emulator.sh start
- name: Verify services are healthy
run: |
EMULATOR_ARCH=${{ matrix.arch }} \
docker/local-emulator/qemu/run-emulator.sh status
- name: Smoke test — backend health
run: curl -sf http://localhost:26701/health?db=1
- name: Smoke test — dashboard reachable
run: curl -sf -o /dev/null -w "HTTP %{http_code}\n" http://localhost:26700/handler/sign-in
- name: Smoke test — MinIO health
run: curl -sf http://localhost:26702/minio/health/live
- name: Smoke test — Inbucket reachable
run: curl -sf -o /dev/null -w "HTTP %{http_code}\n" http://localhost:26703/
- name: Stop emulator
if: always()
run: |
EMULATOR_ARCH=${{ matrix.arch }} \
docker/local-emulator/qemu/run-emulator.sh stop
- name: Print serial log on failure
if: failure()
run: tail -100 docker/local-emulator/qemu/run/vm/serial.log 2>/dev/null || true
publish:
name: Publish to GitHub Releases
needs: build
needs: [build, test]
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || (github.event_name == 'workflow_dispatch' && inputs.publish)
runs-on: ubuntu-latest
permissions:
@ -112,22 +178,22 @@ jobs:
cp "$f" release/
done
cat > release-notes.md <<'EOF'
cat > release-notes.md <<EOF
## QEMU Emulator Images
Built from `${{ github.ref_name }}` @ `${{ github.sha }}`
Built from \`${BRANCH}\` @ \`${GITHUB_SHA}\`
### Images
| File | Description |
|------|-------------|
| `stack-emulator-arm64.qcow2` | ARM64 emulator image |
| `stack-emulator-amd64.qcow2` | AMD64 emulator image |
| \`stack-emulator-arm64.qcow2\` | ARM64 emulator image |
| \`stack-emulator-amd64.qcow2\` | AMD64 emulator image |
### Usage
```bash
\`\`\`bash
stack emulator pull
stack emulator run
```
stack emulator start
\`\`\`
EOF
ls -lh release/

View File

@ -1,54 +0,0 @@
# Stack Auth Local Emulator — all-in-one image via docker compose.
# Ports follow the ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}XX convention.
services:
stack-local:
build:
context: ../..
dockerfile: docker/local-emulator/Dockerfile
image: stack-local-emulator
ports:
- "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28:5432" # PostgreSQL
- "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}29:2500" # Inbucket SMTP
- "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}05:9001" # Inbucket HTTP
- "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}30:1100" # Inbucket POP3
- "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}13:8071" # Svix
- "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}21:9090" # MinIO (S3-compatible)
- "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}25:8080" # QStash
- "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}36:8123" # ClickHouse HTTP
- "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}37:9009" # ClickHouse Native
- "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01:8101" # Dashboard
- "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02:8102" # Backend
volumes:
- postgres-data:/data/postgres
- redis-data:/data/redis
- clickhouse-data:/data/clickhouse
- minio-data:/data/minio
- inbucket-data:/data/inbucket
- "${HOME}:${HOME}"
- "/tmp:/tmp"
env_file: ./.env.development
environment:
# Port-prefixed URLs — need shell interpolation, can't go in env_file
NEXT_PUBLIC_STACK_PORT_PREFIX: "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}"
NEXT_PUBLIC_STACK_API_URL: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02"
NEXT_PUBLIC_STACK_DASHBOARD_URL: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01"
NEXT_PUBLIC_STACK_SVIX_SERVER_URL: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}13"
STACK_S3_PUBLIC_ENDPOINT: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}21/stack-storage"
STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01/handler/email-verification"
STACK_OAUTH_MOCK_URL: "http://host.docker.internal:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}14"
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U postgres && curl -sf http://127.0.0.1:8123/ping && curl -fsS 'http://127.0.0.1:8102/health?db=1' >/dev/null && curl -fsS http://127.0.0.1:8101/handler/sign-in >/dev/null"]
interval: 10s
timeout: 5s
retries: 30
start_period: 120s
volumes:
postgres-data:
redis-data:
clickhouse-data:
minio-data:
inbucket-data:

View File

@ -13,6 +13,13 @@ VM_CPUS="${EMULATOR_CPUS:-4}"
PORT_PREFIX="${PORT_PREFIX:-${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}}"
READY_TIMEOUT="${EMULATOR_READY_TIMEOUT:-240}"
# Fixed host-side ports for the QEMU emulator (267xx range).
# Only user-facing services are exposed; internal deps stay inside the VM.
EMULATOR_DASHBOARD_PORT="${EMULATOR_DASHBOARD_PORT:-26700}"
EMULATOR_BACKEND_PORT="${EMULATOR_BACKEND_PORT:-26701}"
EMULATOR_MINIO_PORT="${EMULATOR_MINIO_PORT:-26702}"
EMULATOR_INBUCKET_PORT="${EMULATOR_INBUCKET_PORT:-26703}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
@ -106,18 +113,13 @@ service_is_up() {
}
deps_ready() {
service_is_up "${PORT_PREFIX}28" tcp &&
service_is_up "${PORT_PREFIX}05" http / &&
service_is_up "${PORT_PREFIX}29" tcp &&
service_is_up "${PORT_PREFIX}13" http /api/v1/health/ &&
service_is_up "${PORT_PREFIX}36" http /ping &&
service_is_up "${PORT_PREFIX}21" http /minio/health/live &&
service_is_up "${PORT_PREFIX}25" http / 401
service_is_up "$EMULATOR_MINIO_PORT" http /minio/health/live &&
service_is_up "$EMULATOR_INBUCKET_PORT" http /
}
app_ready() {
service_is_up "${PORT_PREFIX}02" http "/health?db=1" &&
service_is_up "${PORT_PREFIX}01" http /handler/sign-in
service_is_up "$EMULATOR_BACKEND_PORT" http "/health?db=1" &&
service_is_up "$EMULATOR_DASHBOARD_PORT" http /handler/sign-in
}
all_ready() {
@ -194,19 +196,11 @@ build_qemu_cmd() {
esac
local netdev="user,id=net0"
# Deps services
netdev+=",hostfwd=tcp::${PORT_PREFIX}28-:5432"
netdev+=",hostfwd=tcp::${PORT_PREFIX}29-:2500"
netdev+=",hostfwd=tcp::${PORT_PREFIX}05-:9001"
netdev+=",hostfwd=tcp::${PORT_PREFIX}30-:1100"
netdev+=",hostfwd=tcp::${PORT_PREFIX}13-:8071"
netdev+=",hostfwd=tcp::${PORT_PREFIX}21-:9090"
netdev+=",hostfwd=tcp::${PORT_PREFIX}25-:8080"
netdev+=",hostfwd=tcp::${PORT_PREFIX}36-:8123"
netdev+=",hostfwd=tcp::${PORT_PREFIX}37-:9009"
# App services
netdev+=",hostfwd=tcp::${PORT_PREFIX}01-:${PORT_PREFIX}01"
netdev+=",hostfwd=tcp::${PORT_PREFIX}02-:${PORT_PREFIX}02"
# Only expose user-facing services; internal deps stay inside the VM.
netdev+=",hostfwd=tcp::${EMULATOR_DASHBOARD_PORT}-:${PORT_PREFIX}01"
netdev+=",hostfwd=tcp::${EMULATOR_BACKEND_PORT}-:${PORT_PREFIX}02"
netdev+=",hostfwd=tcp::${EMULATOR_MINIO_PORT}-:9090"
netdev+=",hostfwd=tcp::${EMULATOR_INBUCKET_PORT}-:9001"
QEMU_CMD=(
"$qemu_bin"
@ -251,11 +245,11 @@ tail_vm_logs() {
}
ensure_ports_free() {
local ports=("${PORT_PREFIX}01" "${PORT_PREFIX}02" "${PORT_PREFIX}05" "${PORT_PREFIX}13" "${PORT_PREFIX}21" "${PORT_PREFIX}25" "${PORT_PREFIX}28" "${PORT_PREFIX}29" "${PORT_PREFIX}30" "${PORT_PREFIX}36" "${PORT_PREFIX}37")
local ports=("$EMULATOR_DASHBOARD_PORT" "$EMULATOR_BACKEND_PORT" "$EMULATOR_MINIO_PORT" "$EMULATOR_INBUCKET_PORT")
local port
for port in "${ports[@]}"; do
if lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
err "Port $port is already in use. Stop the Docker emulator or other services first."
err "Port $port is already in use. Stop any conflicting services first."
exit 1
fi
done
@ -297,7 +291,8 @@ cmd_start() {
mkdir -p "$RUN_DIR"
info "Starting QEMU local emulator"
info "Arch: $ARCH | Accel: $ACCEL | Prefix: $PORT_PREFIX"
info "Arch: $ARCH | Accel: $ACCEL"
info "Ports: Dashboard=$EMULATOR_DASHBOARD_PORT Backend=$EMULATOR_BACKEND_PORT MinIO=$EMULATOR_MINIO_PORT Inbucket=$EMULATOR_INBUCKET_PORT"
start_vm
@ -313,7 +308,9 @@ cmd_start() {
exit 1
fi
log "All services are green. The qcow2 overlay preserves emulator state across restarts, while /host stays a live host share outside the VM disk."
log "All services are green."
info "Dashboard: http://localhost:${EMULATOR_DASHBOARD_PORT}"
info "Backend: http://localhost:${EMULATOR_BACKEND_PORT}"
}
cmd_stop() {
@ -354,14 +351,10 @@ cmd_status() {
fi
echo ""
echo "Services:"
print_service_status "Dashboard" "${PORT_PREFIX}01" http /handler/sign-in
print_service_status "Backend" "${PORT_PREFIX}02" http "/health?db=1"
print_service_status "PostgreSQL" "${PORT_PREFIX}28" tcp
print_service_status "Inbucket HTTP" "${PORT_PREFIX}05" http /
print_service_status "Svix" "${PORT_PREFIX}13" http /api/v1/health/
print_service_status "MinIO" "${PORT_PREFIX}21" http /minio/health/live
print_service_status "QStash" "${PORT_PREFIX}25" http / 401
print_service_status "ClickHouse" "${PORT_PREFIX}36" http /ping
print_service_status "Dashboard" "$EMULATOR_DASHBOARD_PORT" http /handler/sign-in
print_service_status "Backend" "$EMULATOR_BACKEND_PORT" http "/health?db=1"
print_service_status "MinIO" "$EMULATOR_MINIO_PORT" http /minio/health/live
print_service_status "Inbucket HTTP" "$EMULATOR_INBUCKET_PORT" http /
exit "$STATUS_FAILED"
}

View File

@ -1,10 +1,7 @@
import { NextResponse } from 'next/server';
import net from 'net';
export const dynamic = 'force-dynamic';
const PORT_PREFIX = process.env.NEXT_PUBLIC_STACK_PORT_PREFIX ?? '81';
type ServiceCheck = {
name: string;
description: string;
@ -15,63 +12,30 @@ type ServiceCheck = {
const SERVICES: ServiceCheck[] = [
{
name: 'PostgreSQL',
description: 'Primary database',
port: Number(`${PORT_PREFIX}28`),
protocol: 'tcp',
},
{
name: 'Inbucket (HTTP)',
description: 'Email capture UI',
port: Number(`${PORT_PREFIX}05`),
name: 'Stack Dashboard',
description: 'Dashboard UI',
port: 26700,
protocol: 'http',
httpPath: '/',
},
{
name: 'Inbucket (SMTP)',
description: 'Email SMTP server',
port: Number(`${PORT_PREFIX}29`),
protocol: 'tcp',
},
{
name: 'Svix',
description: 'Webhook delivery',
port: Number(`${PORT_PREFIX}13`),
protocol: 'http',
httpPath: '/api/v1/health/',
},
{
name: 'ClickHouse',
description: 'Analytics database',
port: Number(`${PORT_PREFIX}36`),
protocol: 'http',
httpPath: '/ping',
},
{
name: 'MinIO (S3)',
description: 'Object storage',
port: Number(`${PORT_PREFIX}21`),
protocol: 'http',
httpPath: '/minio/health/live',
},
{
name: 'QStash',
description: 'Job queue',
port: Number(`${PORT_PREFIX}25`),
protocol: 'http',
httpPath: '/',
httpPath: '/handler/sign-in',
},
{
name: 'Stack Backend',
description: 'API server',
port: Number(`${PORT_PREFIX}02`),
port: 26701,
protocol: 'http',
httpPath: '/',
httpPath: '/health?db=1',
},
{
name: 'Stack Dashboard',
description: 'Dashboard UI',
port: Number(`${PORT_PREFIX}01`),
name: 'MinIO (S3)',
description: 'Object storage',
port: 26702,
protocol: 'http',
httpPath: '/minio/health/live',
},
{
name: 'Inbucket (HTTP)',
description: 'Email capture UI',
port: 26703,
protocol: 'http',
httpPath: '/',
},
@ -90,27 +54,10 @@ async function checkHttp(port: number, path: string, timeoutMs = 3000): Promise<
}
}
async function checkTcp(port: number, timeoutMs = 3000): Promise<{ up: boolean; latencyMs: number }> {
const start = performance.now();
return await new Promise((resolve) => {
const socket = net.createConnection({ host: '127.0.0.1', port }, () => {
socket.destroy();
resolve({ up: true, latencyMs: Math.round(performance.now() - start) });
});
socket.on('error', () => resolve({ up: false, latencyMs: Math.round(performance.now() - start) }));
socket.setTimeout(timeoutMs, () => {
socket.destroy();
resolve({ up: false, latencyMs: Math.round(performance.now() - start) });
});
});
}
export async function GET() {
const results = await Promise.all(
SERVICES.map(async (svc) => {
const check = svc.protocol === 'http'
? await checkHttp(svc.port, svc.httpPath ?? '/')
: await checkTcp(svc.port);
const check = await checkHttp(svc.port, svc.httpPath ?? '/');
return {
name: svc.name,
description: svc.description,

View File

@ -163,16 +163,22 @@ export default function EmulatorStatusPage() {
<Typography type="h4">Quick Start</Typography>
</CardHeader>
<CardContent className="space-y-3">
<Typography className="text-sm">Start the all-in-one local emulator dependencies:</Typography>
<Typography className="text-sm">Start the QEMU local emulator:</Typography>
<pre className="bg-gray-100 dark:bg-gray-900 rounded p-3 text-xs font-mono overflow-x-auto">
{`# Start (single container with all services)
pnpm run emulator:compose up --detach --build
{`# Pull the latest image and start the emulator
pnpm run emulator:start
# Stop and remove volumes
pnpm run emulator:compose down -v`}
# Check service health
pnpm run emulator:status
# Stop (data is preserved)
pnpm run emulator:stop
# Reset for a fresh boot
pnpm run emulator:reset`}
</pre>
<Typography className="text-sm text-gray-500">
This single container replaces the 17+ containers from the full docker-compose setup.
Dashboard: localhost:26700 | Backend: localhost:26701
</Typography>
</CardContent>
</Card>

View File

@ -26,18 +26,12 @@
"deps-compose": "docker compose -p stack-dependencies-${NEXT_PUBLIC_STACK_PORT_PREFIX:-81} -f docker/dependencies/docker.compose.yaml",
"emulator:generate-env": "node ./docker/local-emulator/generate-env-development.mjs",
"emulator:check-env": "node ./docker/local-emulator/generate-env-development.mjs --check",
"emulator:compose": "docker compose -p stack-local-emulator-${NEXT_PUBLIC_STACK_PORT_PREFIX:-81} -f docker/local-emulator/docker-compose.yaml",
"emulator:start": "pnpm pre && pnpm run emulator:generate-env && pnpm run emulator:compose up --detach --build && pnpm run emulator:wait-ready && echo \"\\nLocal emulator started. Dashboard: http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01 Backend: http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02. 'pnpm run emulator:stop' to stop.\"",
"emulator:stop": "pnpm run emulator:compose kill && pnpm run emulator:compose down -v",
"emulator:restart": "pnpm pre && pnpm run emulator:stop && pnpm run emulator:start",
"emulator:wait-ready": "pnpx wait-on http-get://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02/health?db=1 http-get://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01/handler/sign-in",
"emulator:wait-postgres": "until pg_isready -h localhost -p ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28 2>/dev/null; do sleep 1; done",
"emulator:qemu:build": "docker/local-emulator/qemu/build-image.sh",
"emulator:qemu:start": "pnpm run emulator:generate-env && docker/local-emulator/qemu/run-emulator.sh start",
"emulator:qemu:stop": "docker/local-emulator/qemu/run-emulator.sh stop",
"emulator:qemu:reset": "docker/local-emulator/qemu/run-emulator.sh reset",
"emulator:qemu:status": "docker/local-emulator/qemu/run-emulator.sh status",
"emulator:qemu:bench": "docker/local-emulator/qemu/run-emulator.sh bench",
"emulator:start": "pnpm run emulator:generate-env && docker/local-emulator/qemu/run-emulator.sh start",
"emulator:stop": "docker/local-emulator/qemu/run-emulator.sh stop",
"emulator:reset": "docker/local-emulator/qemu/run-emulator.sh reset",
"emulator:status": "docker/local-emulator/qemu/run-emulator.sh status",
"emulator:build": "docker/local-emulator/qemu/build-image.sh",
"emulator:bench": "docker/local-emulator/qemu/run-emulator.sh bench",
"stop-deps": "POSTGRES_DELAY_MS=0 pnpm run deps-compose kill && POSTGRES_DELAY_MS=0 pnpm run deps-compose down -v",
"wait-until-postgres-is-ready:pg_isready": "until pg_isready -h localhost -p ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28 && pg_isready -h localhost -p ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}34; do sleep 1; done",
"wait-until-postgres-is-ready": "command -v pg_isready >/dev/null 2>&1 && pnpm run wait-until-postgres-is-ready:pg_isready || sleep 10 # not everyone has pg_isready installed, so we fallback to sleeping",

View File

@ -4,70 +4,7 @@ import { existsSync, mkdirSync, renameSync, unlinkSync } from "fs";
import { join, resolve } from "path";
import { CliError } from "../lib/errors.js";
const DEFAULT_REPO = "stack-auth/stack-auth";
const DEFAULT_BRANCH = "dev";
const EMULATOR_ARCHES = ["arm64", "amd64"] as const;
type EmulatorArch = typeof EMULATOR_ARCHES[number];
function detectArch(): EmulatorArch {
switch (process.arch) {
case "arm64": {
return "arm64";
}
case "x64": {
return "amd64";
}
default: {
throw new CliError(`Unsupported architecture: ${process.arch}`);
}
}
}
function findQemuDir(): string {
const candidates = [
resolve(process.cwd(), "docker/local-emulator/qemu"),
resolve(process.cwd(), "../docker/local-emulator/qemu"),
];
for (const candidate of candidates) {
if (existsSync(join(candidate, "run-emulator.sh"))) {
return candidate;
}
}
throw new CliError(
"Could not find QEMU emulator directory. Run this from the stack-auth repo root."
);
}
function runCommand(cwd: string, command: string, args: string[], env?: Record<string, string>): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: "inherit",
env: { ...process.env, ...env },
cwd,
});
child.on("close", (code) => {
if (code === 0) resolve();
else reject(new CliError(`${command} exited with code ${code}`));
});
child.on("error", (err) => {
reject(new CliError(`Failed to run ${command}: ${err.message}`));
});
});
}
function runScript(qemuDir: string, script: string, args: string[], env?: Record<string, string>): Promise<void> {
return runCommand(qemuDir, join(qemuDir, script), args, env);
}
function runEmulatorAction(action: string, env?: Record<string, string>): Promise<void> {
return runScript(findQemuDir(), "run-emulator.sh", [action], env);
}
function ghRelease(args: string[]): string {
function gh(args: string[]): string {
try {
return execFileSync("gh", args, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
} catch (err: unknown) {
@ -78,128 +15,124 @@ function ghRelease(args: string[]): string {
}
}
function isValidEmulatorArch(arch: string): arch is EmulatorArch {
return arch === "arm64" || arch === "amd64";
}
function parseEmulatorArch(arch: string | undefined): EmulatorArch {
const resolvedArch = arch ?? detectArch();
if (isValidEmulatorArch(resolvedArch)) {
return resolvedArch;
function findQemuDir(): string {
for (const rel of ["docker/local-emulator/qemu", "../docker/local-emulator/qemu"]) {
const dir = resolve(process.cwd(), rel);
if (existsSync(join(dir, "run-emulator.sh"))) return dir;
}
throw new CliError(`Invalid --arch: ${resolvedArch}; expected one of: ${EMULATOR_ARCHES.join(", ")}.`);
throw new CliError("Could not find QEMU emulator directory. Run this from the stack-auth repo root.");
}
async function pullImage(arch: EmulatorArch, opts: { repo?: string; branch?: string; tag?: string } = {}) {
const repo = opts.repo ?? DEFAULT_REPO;
const branch = opts.branch ?? DEFAULT_BRANCH;
function runEmulator(action: string, env?: Record<string, string>): Promise<void> {
const qemuDir = findQemuDir();
return new Promise((resolve, reject) => {
const child = spawn(join(qemuDir, "run-emulator.sh"), [action], {
stdio: "inherit",
env: { ...process.env, ...env },
cwd: qemuDir,
});
child.on("close", (code) => code === 0 ? resolve() : reject(new CliError(`run-emulator.sh ${action} exited with code ${code}`)));
child.on("error", (err) => reject(new CliError(`Failed to run run-emulator.sh: ${err.message}`)));
});
}
function resolveArch(raw?: string): "arm64" | "amd64" {
const arch = raw ?? (process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "amd64" : null);
if (arch === "arm64" || arch === "amd64") return arch;
throw new CliError(`Invalid architecture: ${raw ?? process.arch}. Expected arm64 or amd64.`);
}
function pullRelease(arch: "arm64" | "amd64", opts: { repo?: string; branch?: string; tag?: string } = {}) {
const repo = opts.repo ?? "stack-auth/stack-auth";
const branch = opts.branch ?? "dev";
const tag = opts.tag ?? `emulator-${branch}-latest`;
const asset = `stack-emulator-${arch}.qcow2`;
const qemuDir = findQemuDir();
const imageDir = join(qemuDir, "images");
const imageDir = join(findQemuDir(), "images");
mkdirSync(imageDir, { recursive: true });
const dest = join(imageDir, asset);
const tmpDest = `${dest}.download`;
console.log(`Pulling image for ${arch} from release ${tag}...`);
console.log(`Pulling ${asset} from release ${tag}...`);
try {
execFileSync("gh", [
"release",
"download",
tag,
"--repo",
repo,
"--pattern",
asset,
"--output",
tmpDest,
"--clobber",
], { stdio: "inherit" });
execFileSync("gh", ["release", "download", tag, "--repo", repo, "--pattern", asset, "--output", tmpDest, "--clobber"], { stdio: "inherit" });
} catch (err) {
if (existsSync(tmpDest)) unlinkSync(tmpDest);
const reason = err instanceof Error
? (err.stack ?? err.message)
: String(err);
throw new CliError(
`Failed to download ${asset} from release ${tag}: ${reason}\nRun 'stack emulator list-releases' to see available releases.`
);
throw new CliError(`Failed to download ${asset} from release ${tag}: ${err instanceof Error ? err.message : err}\nRun 'stack emulator list-releases' to see available releases.`);
}
renameSync(tmpDest, dest);
console.log(`Downloaded: ${dest}`);
}
export function registerEmulatorCommand(program: Command) {
const emulator = program
.command("emulator")
.description("Manage the QEMU local emulator");
const emulator = program.command("emulator").description("Manage the QEMU local emulator");
emulator
.command("pull")
.description("Download the latest emulator image from GitHub Releases")
.option("--arch <arch>", `Target architecture (arm64 or amd64, default: current system arch)`)
.option("--branch <branch>", `Release branch (default: ${DEFAULT_BRANCH})`)
.description("Download an emulator image from GitHub Releases or a PR build")
.option("--arch <arch>", "Target architecture (default: current system arch)")
.option("--branch <branch>", "Release branch (default: dev)")
.option("--tag <tag>", "Specific release tag (default: latest)")
.option("--repo <repo>", `GitHub repository (default: ${DEFAULT_REPO})`)
.option("--repo <repo>", "GitHub repository (default: stack-auth/stack-auth)")
.option("--pr <number>", "Pull from a PR's CI artifacts")
.option("--run <id>", "Pull from a specific workflow run's artifacts")
.action(async (opts) => {
const arch = parseEmulatorArch(opts.arch);
await pullImage(arch, {
repo: opts.repo,
branch: opts.branch,
tag: opts.tag,
});
const arch = resolveArch(opts.arch);
const repo = opts.repo ?? "stack-auth/stack-auth";
if (opts.run || opts.pr) {
let runId = opts.run as string | undefined;
if (!runId) {
console.log(`Finding latest successful build for PR #${opts.pr}...`);
const { headRefName } = JSON.parse(gh(["pr", "view", opts.pr, "--repo", repo, "--json", "headRefName"]));
const runs = JSON.parse(gh(["run", "list", "--repo", repo, "--workflow", "qemu-emulator-build.yaml", "--branch", headRefName, "--status", "success", "--limit", "1", "--json", "databaseId"]));
if (runs.length === 0) throw new CliError(`No successful build found for PR #${opts.pr} (branch: ${headRefName}).`);
runId = String(runs[0].databaseId);
}
const imageDir = join(findQemuDir(), "images");
mkdirSync(imageDir, { recursive: true });
const dest = join(imageDir, `stack-emulator-${arch}.qcow2`);
if (existsSync(dest)) unlinkSync(dest);
console.log(`Downloading qemu-emulator-${arch} from workflow run ${runId}...`);
try {
execFileSync("gh", ["run", "download", runId, "--repo", repo, "--name", `qemu-emulator-${arch}`, "--dir", imageDir], { stdio: "inherit" });
} catch (err) {
throw new CliError(`Failed to download artifact from run ${runId}: ${err instanceof Error ? err.message : err}`);
}
if (!existsSync(dest)) throw new CliError(`Expected image not found at ${dest} after download.`);
console.log(`Downloaded: ${dest}`);
} else {
pullRelease(arch, { repo, branch: opts.branch, tag: opts.tag });
}
});
emulator
.command("start")
.description("Start the emulator in the background (auto-pulls the latest image if none exists)")
.option("--arch <arch>", "Target architecture (arm64 or amd64, default: current system arch). Using a non-native architecture will use software emulation and be significantly slower.")
.option("--arch <arch>", "Target architecture (default: current system arch). Non-native uses software emulation and is significantly slower.")
.action(async (opts) => {
const arch = parseEmulatorArch(opts.arch);
const qemuDir = findQemuDir();
const img = join(qemuDir, "images", `stack-emulator-${arch}.qcow2`);
const arch = resolveArch(opts.arch);
const img = join(findQemuDir(), "images", `stack-emulator-${arch}.qcow2`);
if (!existsSync(img)) {
console.log("No emulator image found. Pulling latest...");
await pullImage(arch);
pullRelease(arch);
}
await runScript(qemuDir, "run-emulator.sh", ["start"], { EMULATOR_ARCH: arch });
await runEmulator("start", { EMULATOR_ARCH: arch });
});
emulator
.command("stop")
.description("Stop the emulator (data is preserved; use 'reset' to clear all state)")
.action(() => runEmulatorAction("stop"));
emulator
.command("reset")
.description("Reset emulator state for a fresh boot")
.action(() => runEmulatorAction("reset"));
emulator
.command("status")
.description("Show emulator and service health")
.action(() => runEmulatorAction("status"));
emulator.command("stop").description("Stop the emulator (data preserved; use 'reset' to clear)").action(() => runEmulator("stop"));
emulator.command("reset").description("Reset emulator state for a fresh boot").action(() => runEmulator("reset"));
emulator.command("status").description("Show emulator and service health").action(() => runEmulator("status"));
emulator
.command("list-releases")
.description("List available emulator releases")
.option("--repo <repo>", `GitHub repository (default: ${DEFAULT_REPO})`)
.action(async (opts) => {
const repo = opts.repo || DEFAULT_REPO;
.option("--repo <repo>", "GitHub repository (default: stack-auth/stack-auth)")
.action((opts) => {
const repo = opts.repo ?? "stack-auth/stack-auth";
console.log(`Available emulator releases from ${repo}:\n`);
const output = ghRelease(["release", "list", "--repo", repo, "--limit", "20"]);
const lines = output.split("\n").filter((l) => l.toLowerCase().includes("emulator"));
if (lines.length === 0) {
console.log("No emulator releases found.");
} else {
for (const line of lines) {
console.log(line);
}
}
const lines = gh(["release", "list", "--repo", repo, "--limit", "20"]).split("\n").filter((l) => l.toLowerCase().includes("emulator"));
if (lines.length === 0) console.log("No emulator releases found.");
else for (const line of lines) console.log(line);
});
}

View File

@ -4976,14 +4976,6 @@ packages:
'@types/node':
optional: true
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
'@isaacs/brace-expansion@5.0.0':
resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==}
engines: {node: 20 || >=22}
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@ -13581,10 +13573,6 @@ packages:
resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==}
engines: {node: 20 || >=22}
minimatch@10.1.1:
resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==}
engines: {node: 20 || >=22}
minimatch@10.2.4:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
@ -19873,12 +19861,6 @@ snapshots:
optionalDependencies:
'@types/node': 20.17.6
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.0':
dependencies:
'@isaacs/balanced-match': 4.0.1
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@ -25529,7 +25511,7 @@ snapshots:
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3
minimatch: 10.1.1
minimatch: 10.2.4
path-browserify: 1.0.1
'@turf/boolean-point-in-polygon@7.1.0':
@ -28542,7 +28524,7 @@ snapshots:
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1)
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
eslint-plugin-react: 7.37.2(eslint@8.57.1)
eslint-plugin-react-hooks: 5.1.0(eslint@8.57.1)
@ -28566,7 +28548,7 @@ snapshots:
debug: 4.4.3
enhanced-resolve: 5.17.0
eslint: 8.57.1
eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1)
eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1)
fast-glob: 3.3.2
get-tsconfig: 4.8.1
@ -28616,7 +28598,7 @@ snapshots:
- eslint-import-resolver-webpack
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1):
eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
dependencies:
debug: 3.2.7
optionalDependencies:
@ -28676,7 +28658,7 @@ snapshots:
doctrine: 2.1.0
eslint: 8.57.1
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.1)
eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
hasown: 2.0.2
is-core-module: 2.15.1
is-glob: 4.0.3
@ -29788,7 +29770,7 @@ snapshots:
glob@13.0.0:
dependencies:
minimatch: 10.1.1
minimatch: 10.2.4
minipass: 7.1.2
path-scurry: 2.0.0
@ -31481,10 +31463,6 @@ snapshots:
dependencies:
brace-expansion: 2.0.1
minimatch@10.1.1:
dependencies:
'@isaacs/brace-expansion': 5.0.0
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.4