diff --git a/.github/workflows/qemu-emulator-build.yaml b/.github/workflows/qemu-emulator-build.yaml index cc9d8e9ea..9b10347e2 100644 --- a/.github/workflows/qemu-emulator-build.yaml +++ b/.github/workflows/qemu-emulator-build.yaml @@ -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 </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: diff --git a/docker/local-emulator/qemu/run-emulator.sh b/docker/local-emulator/qemu/run-emulator.sh index 6e09b7746..f2f3028ca 100755 --- a/docker/local-emulator/qemu/run-emulator.sh +++ b/docker/local-emulator/qemu/run-emulator.sh @@ -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" } diff --git a/examples/demo/src/app/api/emulator-status/route.ts b/examples/demo/src/app/api/emulator-status/route.ts index 34e08145c..1cf488711 100644 --- a/examples/demo/src/app/api/emulator-status/route.ts +++ b/examples/demo/src/app/api/emulator-status/route.ts @@ -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, diff --git a/examples/demo/src/app/emulator-status/page.tsx b/examples/demo/src/app/emulator-status/page.tsx index e52b50243..61c57e22b 100644 --- a/examples/demo/src/app/emulator-status/page.tsx +++ b/examples/demo/src/app/emulator-status/page.tsx @@ -163,16 +163,22 @@ export default function EmulatorStatusPage() { Quick Start - Start the all-in-one local emulator dependencies: + Start the QEMU local emulator:
-              {`# 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`}
             
- This single container replaces the 17+ containers from the full docker-compose setup. + Dashboard: localhost:26700 | Backend: localhost:26701
diff --git a/package.json b/package.json index 1a23f25ca..0aca2a60c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/stack-cli/src/commands/emulator.ts b/packages/stack-cli/src/commands/emulator.ts index e71ed5379..a4878b237 100644 --- a/packages/stack-cli/src/commands/emulator.ts +++ b/packages/stack-cli/src/commands/emulator.ts @@ -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): Promise { - 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): Promise { - return runCommand(qemuDir, join(qemuDir, script), args, env); -} - -function runEmulatorAction(action: string, env?: Record): Promise { - 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): Promise { + 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 ", `Target architecture (arm64 or amd64, default: current system arch)`) - .option("--branch ", `Release branch (default: ${DEFAULT_BRANCH})`) + .description("Download an emulator image from GitHub Releases or a PR build") + .option("--arch ", "Target architecture (default: current system arch)") + .option("--branch ", "Release branch (default: dev)") .option("--tag ", "Specific release tag (default: latest)") - .option("--repo ", `GitHub repository (default: ${DEFAULT_REPO})`) + .option("--repo ", "GitHub repository (default: stack-auth/stack-auth)") + .option("--pr ", "Pull from a PR's CI artifacts") + .option("--run ", "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 ", "Target architecture (arm64 or amd64, default: current system arch). Using a non-native architecture will use software emulation and be significantly slower.") + .option("--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 ", `GitHub repository (default: ${DEFAULT_REPO})`) - .action(async (opts) => { - const repo = opts.repo || DEFAULT_REPO; + .option("--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); }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed8650826..77dfbbbb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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