mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Merge branch 'dev' into dario-likes-mcps
Some checks failed
DB migration compat / Check if migrations changed (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
Some checks failed
DB migration compat / Check if migrations changed (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
This commit is contained in:
commit
b82efa4210
@ -61,6 +61,8 @@ dist
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
**.tsbuildinfo
|
||||
docker/local-emulator/qemu/images
|
||||
docker/local-emulator/qemu/run
|
||||
|
||||
.xata*
|
||||
|
||||
@ -149,4 +151,3 @@ packages/stack/*
|
||||
!packages/react/package.json
|
||||
!packages/next/package.json
|
||||
!packages/stack/package.json
|
||||
|
||||
|
||||
255
.github/workflows/qemu-emulator-build.yaml
vendored
Normal file
255
.github/workflows/qemu-emulator-build.yaml
vendored
Normal file
@ -0,0 +1,255 @@
|
||||
name: Build & Publish QEMU Emulator Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docker/local-emulator/**'
|
||||
- '.github/workflows/qemu-emulator-build.yaml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
publish:
|
||||
description: 'Publish images to GitHub Releases'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
|
||||
|
||||
env:
|
||||
EMULATOR_IMAGE_NAME: stack-local-emulator
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build QEMU Image (${{ matrix.arch }})
|
||||
runs-on: ubicloud-standard-8
|
||||
timeout-minutes: 120
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
- arch: arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU user-mode emulation
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Install QEMU dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y qemu-system-x86 qemu-system-arm qemu-utils genisoimage socat qemu-efi-aarch64
|
||||
|
||||
- name: Build QEMU image
|
||||
run: |
|
||||
chmod +x docker/local-emulator/qemu/build-image.sh
|
||||
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
|
||||
EMULATOR_ARCH=${{ matrix.arch }} \
|
||||
EMULATOR_READY_TIMEOUT=3200 \
|
||||
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: Stop emulator
|
||||
if: always()
|
||||
run: |
|
||||
EMULATOR_ARCH=${{ matrix.arch }} \
|
||||
docker/local-emulator/qemu/run-emulator.sh stop
|
||||
|
||||
- name: Package image
|
||||
run: |
|
||||
BASE_IMG="docker/local-emulator/qemu/images/stack-emulator-${{ matrix.arch }}.qcow2"
|
||||
cp "$BASE_IMG" "stack-emulator-${{ matrix.arch }}.qcow2"
|
||||
|
||||
- name: Upload image artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: qemu-emulator-${{ matrix.arch }}
|
||||
path: stack-emulator-${{ matrix.arch }}.qcow2
|
||||
retention-days: 30
|
||||
compression-level: 0
|
||||
|
||||
test:
|
||||
name: Smoke Test (${{ matrix.arch }})
|
||||
needs: build
|
||||
runs-on: ubicloud-standard-8
|
||||
timeout-minutes: 60
|
||||
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, test]
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || (github.event_name == 'workflow_dispatch' && inputs.publish)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare release assets
|
||||
run: |
|
||||
mkdir -p release
|
||||
SHORT_SHA="${GITHUB_SHA:0:8}"
|
||||
BRANCH="${GITHUB_REF_NAME}"
|
||||
DATE="$(date -u +%Y%m%d)"
|
||||
TAG="emulator-${BRANCH}-${DATE}-${SHORT_SHA}"
|
||||
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
|
||||
echo "SHORT_SHA=${SHORT_SHA}" >> "$GITHUB_ENV"
|
||||
|
||||
for f in artifacts/qemu-emulator-*/*.qcow2; do
|
||||
cp "$f" release/
|
||||
done
|
||||
|
||||
cat > release-notes.md <<EOF
|
||||
## QEMU Emulator Images
|
||||
|
||||
Built from \`${BRANCH}\` @ \`${GITHUB_SHA}\`
|
||||
|
||||
### Images
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| \`stack-emulator-arm64.qcow2\` | ARM64 emulator image |
|
||||
| \`stack-emulator-amd64.qcow2\` | AMD64 emulator image |
|
||||
|
||||
### Usage
|
||||
\`\`\`bash
|
||||
stack emulator pull
|
||||
stack emulator start
|
||||
\`\`\`
|
||||
EOF
|
||||
|
||||
ls -lh release/
|
||||
|
||||
- name: Create or update GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TITLE="QEMU Emulator — ${{ github.ref_name }} ($SHORT_SHA)"
|
||||
|
||||
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
gh release edit "$RELEASE_TAG" \
|
||||
--title "$TITLE" \
|
||||
--notes-file release-notes.md \
|
||||
--prerelease
|
||||
gh release upload "$RELEASE_TAG" release/* --clobber
|
||||
else
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--title "$TITLE" \
|
||||
--notes-file release-notes.md \
|
||||
--prerelease \
|
||||
release/*
|
||||
fi
|
||||
|
||||
- name: Update latest tag for branch
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
LATEST_TAG="emulator-${{ github.ref_name }}-latest"
|
||||
TITLE="QEMU Emulator — ${{ github.ref_name }} (latest)"
|
||||
NOTES="Latest emulator images from \`${{ github.ref_name }}\`. Auto-updated on each build."
|
||||
|
||||
if gh release view "$LATEST_TAG" >/dev/null 2>&1; then
|
||||
gh release edit "$LATEST_TAG" \
|
||||
--draft \
|
||||
--prerelease \
|
||||
--target "${{ github.sha }}" \
|
||||
--title "$TITLE" \
|
||||
--notes "$NOTES"
|
||||
else
|
||||
gh release create "$LATEST_TAG" \
|
||||
--draft \
|
||||
--prerelease \
|
||||
--target "${{ github.sha }}" \
|
||||
--title "$TITLE" \
|
||||
--notes "$NOTES" \
|
||||
|| gh release edit "$LATEST_TAG" \
|
||||
--draft \
|
||||
--prerelease \
|
||||
--target "${{ github.sha }}" \
|
||||
--title "$TITLE" \
|
||||
--notes "$NOTES"
|
||||
fi
|
||||
|
||||
gh release upload "$LATEST_TAG" release/* --clobber
|
||||
gh release edit "$LATEST_TAG" --draft=false --prerelease
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -24,6 +24,7 @@ vite.config.ts.timestamp-*
|
||||
.eslintcache
|
||||
.env.local
|
||||
.env.*.local
|
||||
docker/local-emulator/.env.development
|
||||
scratch/
|
||||
|
||||
npm-debug.log*
|
||||
|
||||
@ -502,6 +502,13 @@ export async function seed() {
|
||||
} else {
|
||||
console.log('Ensured emulator user is a member of emulator team');
|
||||
}
|
||||
|
||||
await grantTeamPermission(internalPrisma, {
|
||||
tenancy: internalTenancy,
|
||||
teamId: LOCAL_EMULATOR_OWNER_TEAM_ID,
|
||||
userId: LOCAL_EMULATOR_ADMIN_USER_ID,
|
||||
permissionId: "team_admin",
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Seeding complete!');
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
LOCAL_EMULATOR_OWNER_TEAM_ID,
|
||||
isLocalEmulatorEnabled,
|
||||
readConfigFromFile,
|
||||
resolveEmulatorPath,
|
||||
writeConfigToFile,
|
||||
} from "@/lib/local-emulator";
|
||||
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
|
||||
@ -192,11 +193,12 @@ export const POST = createSmartRouteHandler({
|
||||
}
|
||||
|
||||
const absoluteFilePath = path.resolve(req.body.absolute_file_path);
|
||||
const resolvedFilePath = resolveEmulatorPath(absoluteFilePath);
|
||||
|
||||
// Validate file exists before creating a project
|
||||
let fileExists: boolean;
|
||||
try {
|
||||
await fs.access(absoluteFilePath);
|
||||
await fs.access(resolvedFilePath);
|
||||
fileExists = true;
|
||||
} catch {
|
||||
fileExists = false;
|
||||
@ -206,7 +208,7 @@ export const POST = createSmartRouteHandler({
|
||||
}
|
||||
|
||||
// If the file is empty, write a default config
|
||||
const fileContent = await fs.readFile(absoluteFilePath, "utf-8");
|
||||
const fileContent = await fs.readFile(resolvedFilePath, "utf-8");
|
||||
if (fileContent.trim() === "") {
|
||||
await writeConfigToFile(absoluteFilePath, {});
|
||||
}
|
||||
|
||||
92
apps/backend/src/lib/local-emulator.test.ts
Normal file
92
apps/backend/src/lib/local-emulator.test.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import fs from "fs/promises";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV,
|
||||
readConfigFromFile,
|
||||
writeConfigToFile,
|
||||
} from "./local-emulator";
|
||||
|
||||
describe("local emulator config", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reads config from STACK_LOCAL_EMULATOR_CONFIG_CONTENT env var when set", async () => {
|
||||
const content = `export const config = { auth: { allowLocalhost: true } };\n`;
|
||||
vi.stubEnv("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", Buffer.from(content).toString("base64"));
|
||||
|
||||
await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).resolves.toMatchInlineSnapshot(`
|
||||
{
|
||||
"auth": {
|
||||
"allowLocalhost": true,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("returns empty object when env var is not set and file does not exist", async () => {
|
||||
await expect(readConfigFromFile("/nonexistent/stack.config.ts")).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object when env var content is empty", async () => {
|
||||
const content = ``;
|
||||
vi.stubEnv("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", Buffer.from(content).toString("base64"));
|
||||
|
||||
await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it("throws when the config module does not export config", async () => {
|
||||
const content = `export default { auth: { allowLocalhost: true } };\n`;
|
||||
vi.stubEnv("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", Buffer.from(content).toString("base64"));
|
||||
|
||||
await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).rejects.toThrow(
|
||||
"Invalid config in /irrelevant/path/stack.config.ts. The file must export a 'config' object."
|
||||
);
|
||||
});
|
||||
|
||||
it("reads config files from the host mount when configured", async () => {
|
||||
const hostMountRoot = await fs.mkdtemp(path.join(os.tmpdir(), "stack-host-mount-"));
|
||||
const absoluteFilePath = "/Users/foo/project/stack.config.ts";
|
||||
const mountedFilePath = path.join(hostMountRoot, absoluteFilePath);
|
||||
await fs.mkdir(path.dirname(mountedFilePath), { recursive: true });
|
||||
await fs.writeFile(mountedFilePath, `export const config = { auth: { allowLocalhost: true } };\n`, "utf-8");
|
||||
|
||||
vi.stubEnv(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, hostMountRoot);
|
||||
|
||||
await expect(readConfigFromFile(absoluteFilePath)).resolves.toMatchInlineSnapshot(`
|
||||
{
|
||||
"auth": {
|
||||
"allowLocalhost": true,
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it("writes new config files to the host mount when the mounted parent directory exists", async () => {
|
||||
const hostMountRoot = await fs.mkdtemp(path.join(os.tmpdir(), "stack-host-mount-"));
|
||||
const absoluteFilePath = "/Users/foo/project/stack.config.ts";
|
||||
const mountedParentPath = path.join(hostMountRoot, "/Users/foo/project");
|
||||
const mountedFilePath = path.join(hostMountRoot, absoluteFilePath);
|
||||
await fs.mkdir(mountedParentPath, { recursive: true });
|
||||
|
||||
vi.stubEnv(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, hostMountRoot);
|
||||
|
||||
await writeConfigToFile(absoluteFilePath, { auth: { allowLocalhost: true } });
|
||||
|
||||
await expect(fs.readFile(mountedFilePath, "utf-8")).resolves.toBe(
|
||||
`export const config = {\n "auth": {\n "allowLocalhost": true\n }\n};\n`
|
||||
);
|
||||
});
|
||||
|
||||
it("fails loudly when the QEMU host mount root is configured but unavailable", async () => {
|
||||
const hostMountRoot = await fs.mkdtemp(path.join(os.tmpdir(), "stack-host-mount-"));
|
||||
vi.stubEnv(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, hostMountRoot);
|
||||
|
||||
await expect(writeConfigToFile("/Users/foo/project/stack.config.ts", { auth: { allowLocalhost: true } })).rejects.toThrow(
|
||||
`Local emulator host mount root ${hostMountRoot} is configured`
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -1,10 +1,10 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { createJiti } from "jiti";
|
||||
import { globalPrismaClient } from "@/prisma-client";
|
||||
import { isValidConfig } from "@stackframe/stack-shared/dist/config/format";
|
||||
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { globalPrismaClient } from "@/prisma-client";
|
||||
import fs from "fs/promises";
|
||||
import { createJiti } from "jiti";
|
||||
import path from "path";
|
||||
|
||||
export const LOCAL_EMULATOR_ADMIN_USER_ID = "63abbc96-5329-454a-ba56-e0460173c6c1";
|
||||
export const LOCAL_EMULATOR_OWNER_TEAM_ID = "5a0c858b-d9e9-49d4-9943-8ce385d86428";
|
||||
@ -15,6 +15,7 @@ export const LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE =
|
||||
"Environment configuration overrides cannot be changed in the local emulator. Update this in your production deployment instead.";
|
||||
export const LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE =
|
||||
"This endpoint is only available in local emulator mode (set NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true).";
|
||||
export const LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV = "STACK_LOCAL_EMULATOR_HOST_MOUNT_ROOT";
|
||||
|
||||
export function isLocalEmulatorEnabled() {
|
||||
return getEnvVariable("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", "") === "true";
|
||||
@ -44,16 +45,36 @@ export async function getLocalEmulatorFilePath(projectId: string): Promise<strin
|
||||
return result?.absoluteFilePath ?? null;
|
||||
}
|
||||
|
||||
export async function readConfigFromFile(filePath: string): Promise<Record<string, unknown>> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(filePath, "utf-8");
|
||||
} catch (e: any) {
|
||||
if (e?.code === "ENOENT") {
|
||||
throw new StatusError(StatusError.BadRequest, `Config file not found: ${filePath}`);
|
||||
}
|
||||
throw e;
|
||||
export function resolveEmulatorPath(filePath: string): string {
|
||||
const hostMountRoot = getEnvVariable(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, "");
|
||||
if (hostMountRoot) {
|
||||
return path.join(hostMountRoot, filePath);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export async function readConfigFromFile(filePath: string): Promise<Record<string, unknown>> {
|
||||
// Check for base64-encoded config content override from env var
|
||||
const envContent = getEnvVariable("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", "");
|
||||
let content: string;
|
||||
if (envContent) {
|
||||
content = Buffer.from(envContent, "base64").toString("utf-8");
|
||||
} else {
|
||||
const resolvedPath = resolveEmulatorPath(filePath);
|
||||
try {
|
||||
content = await fs.readFile(resolvedPath, "utf-8");
|
||||
} catch (e: any) {
|
||||
if (e?.code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (content.trim() === "") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const jiti = createJiti(import.meta.url, { cache: false });
|
||||
const mod = jiti.evalModule(content, { filename: filePath }) as Record<string, unknown>;
|
||||
const config = mod.config;
|
||||
@ -64,8 +85,18 @@ export async function readConfigFromFile(filePath: string): Promise<Record<strin
|
||||
}
|
||||
|
||||
export async function writeConfigToFile(filePath: string, config: Record<string, unknown>): Promise<void> {
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const resolvedPath = resolveEmulatorPath(filePath);
|
||||
const dir = path.dirname(resolvedPath);
|
||||
const hostMountRoot = getEnvVariable(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, "");
|
||||
if (hostMountRoot) {
|
||||
try {
|
||||
await fs.access(dir);
|
||||
} catch {
|
||||
throw new Error(`Local emulator host mount root ${hostMountRoot} is configured but the parent directory for ${filePath} is not available at ${dir}. Ensure the host filesystem is mounted correctly.`);
|
||||
}
|
||||
} else {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
const content = `export const config = ${JSON.stringify(config, null, 2)};\n`;
|
||||
await fs.writeFile(filePath, content, "utf-8");
|
||||
await fs.writeFile(resolvedPath, content, "utf-8");
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ import { getPublicEnvVar } from "@/lib/env";
|
||||
import { stackAppInternalsSymbol } from "@/lib/stack-app-internals";
|
||||
import { GearIcon } from "@phosphor-icons/react";
|
||||
import { AdminOwnedProject, Team, useStackApp, useUser } from "@stackframe/stack";
|
||||
import { projectOnboardingStatusValues, strictEmailSchema, type ProjectOnboardingStatus, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { projectOnboardingStatusValues, strictEmailSchema, yupObject, type ProjectOnboardingStatus } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { groupBy } from "@stackframe/stack-shared/dist/utils/arrays";
|
||||
import { runAsynchronously, runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import { useQueryState } from "@stackframe/stack-shared/dist/utils/react";
|
||||
|
||||
@ -4,7 +4,8 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-a
|
||||
import { AppStoreEntry } from "@/components/app-store-entry";
|
||||
import { useRouter } from "@/components/router";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui";
|
||||
import { ALL_APPS_FRONTEND, getAppPath } from "@/lib/apps-frontend";
|
||||
import { ALL_APPS_FRONTEND, getAppPath, isSubApp } from "@/lib/apps-frontend";
|
||||
import { isAppEnabled } from "@/lib/apps-utils";
|
||||
import { useUpdateConfig } from "@/lib/config-update";
|
||||
import { AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
|
||||
import { usePathname } from "next/navigation";
|
||||
@ -20,7 +21,16 @@ export default function AppDetailsModalPageClient({ appId }: { appId: AppId }) {
|
||||
const config = project.useConfig();
|
||||
const updateConfig = useUpdateConfig();
|
||||
|
||||
const isEnabled = config.apps.installed[appId]?.enabled ?? false;
|
||||
const isEnabled = isAppEnabled(config.apps.installed, appId);
|
||||
const appFrontend = ALL_APPS_FRONTEND[appId];
|
||||
const appPath = getAppPath(project.id, appFrontend);
|
||||
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
|
||||
const parentAppEnabled = parentAppId == null ? false : isAppEnabled(config.apps.installed, parentAppId);
|
||||
const subAppDestinationPath = parentAppId == null
|
||||
? null
|
||||
: parentAppEnabled
|
||||
? appPath
|
||||
: `/projects/${project.id}/apps/${parentAppId}`;
|
||||
|
||||
// Control modal visibility based on whether we're on a modal route.
|
||||
// This ensures the modal only closes when navigation actually succeeds,
|
||||
@ -47,9 +57,8 @@ export default function AppDetailsModalPageClient({ appId }: { appId: AppId }) {
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
const path = getAppPath(project.id, ALL_APPS_FRONTEND[appId]);
|
||||
// Navigate to the app page. Modal stays open until pathname changes.
|
||||
router.replace(path);
|
||||
router.replace(subAppDestinationPath ?? appPath);
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from "@/components/router";
|
||||
import { isAppEnabled } from "@/lib/apps-utils";
|
||||
import { AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
|
||||
import { Typography } from "@/components/ui";
|
||||
import type { ReactNode } from "react";
|
||||
@ -13,7 +14,7 @@ export function AppEnabledGuard(props: { appId: AppId, children: ReactNode }) {
|
||||
const adminApp = useAdminApp();
|
||||
const project = adminApp.useProject();
|
||||
const config = project.useConfig();
|
||||
const isEnabled = config.apps.installed[props.appId]?.enabled;
|
||||
const isEnabled = isAppEnabled(config.apps.installed, props.appId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) {
|
||||
|
||||
@ -4,7 +4,8 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-a
|
||||
import { AppStoreEntry } from "@/components/app-store-entry";
|
||||
import { useRouter } from "@/components/router";
|
||||
import { useUpdateConfig } from "@/lib/config-update";
|
||||
import { ALL_APPS_FRONTEND, getAppPath, type AppId } from "@/lib/apps-frontend";
|
||||
import { ALL_APPS_FRONTEND, getAppPath, isSubApp, type AppId } from "@/lib/apps-frontend";
|
||||
import { isAppEnabled } from "@/lib/apps-utils";
|
||||
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import { PageLayout } from "../../page-layout";
|
||||
@ -17,13 +18,21 @@ export default function AppDetailsPageClient({ appId }: { appId: AppId }) {
|
||||
const config = project.useConfig();
|
||||
const updateConfig = useUpdateConfig();
|
||||
|
||||
const isEnabled = config.apps.installed[appId]?.enabled ?? false;
|
||||
const isEnabled = isAppEnabled(config.apps.installed, appId);
|
||||
|
||||
const appFrontend = ALL_APPS_FRONTEND[appId];
|
||||
if (!(appFrontend as any)) {
|
||||
throw new StackAssertionError(`App frontend not found for appId: ${appId}`, { appId });
|
||||
}
|
||||
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
|
||||
const parentAppFrontend = parentAppId == null ? null : ALL_APPS_FRONTEND[parentAppId];
|
||||
const parentAppEnabled = parentAppId == null ? false : isAppEnabled(config.apps.installed, parentAppId);
|
||||
const appPath = getAppPath(project.id, appFrontend);
|
||||
const subAppDestinationPath = parentAppFrontend == null
|
||||
? null
|
||||
: parentAppEnabled
|
||||
? appPath
|
||||
: `/projects/${project.id}/apps/${parentAppId}`;
|
||||
|
||||
const handleEnable = async () => {
|
||||
await updateConfig({
|
||||
@ -35,7 +44,7 @@ export default function AppDetailsPageClient({ appId }: { appId: AppId }) {
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
router.push(appPath);
|
||||
router.push(subAppDestinationPath ?? appPath);
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
|
||||
@ -4,6 +4,7 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-a
|
||||
import { AppSquare } from "@/components/app-square";
|
||||
import { DesignAlert, DesignCard, DesignCategoryTabs, DesignInput } from "@/components/design-components";
|
||||
import { type AppId } from "@/lib/apps-frontend";
|
||||
import { getEnabledAppIds } from "@/lib/apps-utils";
|
||||
import { CheckCircleIcon, MagnifyingGlassIcon, SquaresFourIcon } from "@phosphor-icons/react";
|
||||
import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config";
|
||||
import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
|
||||
@ -34,9 +35,7 @@ export default function PageClient() {
|
||||
|
||||
// Get installed apps
|
||||
const installedApps = useMemo(() =>
|
||||
(Object.entries(config.apps.installed) as [string, { enabled?: boolean } | undefined][])
|
||||
.filter(([_, appConfig]) => appConfig?.enabled)
|
||||
.map(([appId]) => appId as AppId),
|
||||
getEnabledAppIds(config.apps.installed),
|
||||
[config.apps.installed]
|
||||
);
|
||||
|
||||
|
||||
@ -18,7 +18,8 @@ import {
|
||||
TooltipTrigger,
|
||||
Typography,
|
||||
} from "@/components/ui";
|
||||
import { ALL_APPS_FRONTEND, DUMMY_ORIGIN, getAppPath, getItemPath, testAppPath, testItemPath } from "@/lib/apps-frontend";
|
||||
import { ALL_APPS_FRONTEND, DUMMY_ORIGIN, getAppPath, getItemPath, hasNavigationItems, testAppPath, testItemPath, type NavigableAppFrontend } from "@/lib/apps-frontend";
|
||||
import { getEnabledAppIds, getEnabledNavigableAppIds } from "@/lib/apps-utils";
|
||||
import { useUpdateConfig } from "@/lib/config-update";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@ -37,7 +38,6 @@ import {
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import { UserButton } from "@stackframe/stack";
|
||||
import { ALL_APPS, type AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
|
||||
import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useAdminApp, useProjectId } from "./use-admin-app";
|
||||
@ -381,10 +381,14 @@ function AppNavItem({
|
||||
|
||||
// Memoize the item object to prevent NavItem re-renders
|
||||
const navItemData = useMemo(() => {
|
||||
const items = appFrontend.navigationItems.map((navItem) => ({
|
||||
if (!hasNavigationItems(appFrontend)) {
|
||||
return null;
|
||||
}
|
||||
const navigableFrontend: NavigableAppFrontend = appFrontend;
|
||||
const items = navigableFrontend.navigationItems.map((navItem) => ({
|
||||
name: navItem.displayName,
|
||||
href: getItemPath(projectId, appFrontend, navItem),
|
||||
match: (fullUrl: URL) => testItemPath(projectId, appFrontend, navItem, fullUrl),
|
||||
href: getItemPath(projectId, navigableFrontend, navItem),
|
||||
match: (fullUrl: URL) => testItemPath(projectId, navigableFrontend, navItem, fullUrl),
|
||||
}));
|
||||
return {
|
||||
name: app.displayName,
|
||||
@ -396,6 +400,10 @@ function AppNavItem({
|
||||
};
|
||||
}, [app.displayName, appId, appFrontend, projectId]);
|
||||
|
||||
if (navItemData == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NavItem
|
||||
item={navItemData}
|
||||
@ -426,9 +434,7 @@ function SidebarContent({
|
||||
|
||||
// Memoize enabledApps to prevent recalculation on every render
|
||||
const enabledApps = useMemo(() =>
|
||||
typedEntries(config.apps.installed)
|
||||
.filter(([appId, appConfig]) => appConfig?.enabled && appId in ALL_APPS)
|
||||
.map(([appId]) => appId as AppId),
|
||||
getEnabledNavigableAppIds(config.apps.installed),
|
||||
[config.apps.installed]
|
||||
);
|
||||
|
||||
@ -608,9 +614,7 @@ function SpotlightSearchWrapper({ projectId }: { projectId: string }) {
|
||||
const updateConfig = useUpdateConfig();
|
||||
|
||||
const enabledApps = useMemo(() =>
|
||||
typedEntries(config.apps.installed)
|
||||
.filter(([appId, appConfig]) => appConfig?.enabled && appId in ALL_APPS)
|
||||
.map(([appId]) => appId as AppId),
|
||||
getEnabledAppIds(config.apps.installed),
|
||||
[config.apps.installed]
|
||||
);
|
||||
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { useAdminApp, useProjectId } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
|
||||
import { useRouter } from "@/components/router";
|
||||
import { Button, cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui";
|
||||
import { ALL_APPS_FRONTEND, AppFrontend, getAppPath } from "@/lib/apps-frontend";
|
||||
import { ALL_APPS_FRONTEND, AppFrontend, getAppPath, isSubApp } from "@/lib/apps-frontend";
|
||||
import { isAppEnabled } from "@/lib/apps-utils";
|
||||
import { useUpdateConfig } from "@/lib/config-update";
|
||||
import { CheckIcon, DotsThreeVerticalIcon } from "@phosphor-icons/react";
|
||||
import { ALL_APPS, AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
|
||||
@ -57,9 +59,20 @@ export function AppSquare({
|
||||
const project = adminApp.useProject();
|
||||
const config = project.useConfig();
|
||||
const updateConfig = useUpdateConfig();
|
||||
const router = useRouter();
|
||||
|
||||
const isEnabled = config.apps.installed[appId]?.enabled ?? false;
|
||||
const isEnabled = isAppEnabled(config.apps.installed, appId);
|
||||
const appDetailsPath = `/projects/${projectId}/apps/${appId}`;
|
||||
const appFrontend = ALL_APPS_FRONTEND[appId];
|
||||
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
|
||||
const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId];
|
||||
const parentAppFrontend = parentAppId == null ? null : ALL_APPS_FRONTEND[parentAppId];
|
||||
const parentAppEnabled = parentAppId == null ? false : isAppEnabled(config.apps.installed, parentAppId);
|
||||
const parentDestinationPath = parentAppId == null || parentAppFrontend == null
|
||||
? null
|
||||
: parentAppEnabled
|
||||
? getAppPath(projectId, appFrontend)
|
||||
: `/projects/${projectId}/apps/${parentAppId}`;
|
||||
|
||||
const handleToggleEnabled = async () => {
|
||||
// Show warning modal for alpha/beta apps when enabling
|
||||
@ -138,9 +151,15 @@ export function AppSquare({
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[120px]">
|
||||
<DropdownMenuItem onClick={handleToggleEnabled} className="cursor-pointer">
|
||||
{isEnabled ? 'Disable' : 'Enable'}
|
||||
</DropdownMenuItem>
|
||||
{parentDestinationPath == null ? (
|
||||
<DropdownMenuItem onClick={handleToggleEnabled} className="cursor-pointer">
|
||||
{isEnabled ? 'Disable' : 'Enable'}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => router.push(parentDestinationPath)} className="cursor-pointer">
|
||||
Go to {parentApp?.displayName ?? "parent app"}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@ -199,9 +218,19 @@ export function AppListItem({
|
||||
const project = adminApp.useProject();
|
||||
const config = project.useConfig();
|
||||
|
||||
const isEnabled = config.apps.installed[appId]?.enabled ?? false;
|
||||
const isEnabled = isAppEnabled(config.apps.installed, appId);
|
||||
const appPath = getAppPath(project.id, appFrontend);
|
||||
const appDetailsPath = `/projects/${project.id}/apps/${appId}`;
|
||||
const router = useRouter();
|
||||
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
|
||||
const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId];
|
||||
const parentAppFrontend = parentAppId == null ? null : ALL_APPS_FRONTEND[parentAppId];
|
||||
const parentAppEnabled = parentAppId == null ? false : isAppEnabled(config.apps.installed, parentAppId);
|
||||
const parentDestinationPath = parentAppId == null || parentAppFrontend == null
|
||||
? null
|
||||
: parentAppEnabled
|
||||
? appPath
|
||||
: `/projects/${project.id}/apps/${parentAppId}`;
|
||||
|
||||
const handleEnable = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@ -220,7 +249,7 @@ export function AppListItem({
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={isEnabled ? appPath : appDetailsPath}
|
||||
href={parentDestinationPath ?? (isEnabled ? appPath : appDetailsPath)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 rounded-lg transition-all",
|
||||
"hover:bg-gray-50 dark:hover:bg-gray-800/50",
|
||||
@ -258,6 +287,19 @@ export function AppListItem({
|
||||
<div className="flex items-center gap-2">
|
||||
{isEnabled ? (
|
||||
<CheckIcon className="w-4 h-4 text-green-500" />
|
||||
) : parentDestinationPath != null ? (
|
||||
<Button
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
router.push(parentDestinationPath);
|
||||
}}
|
||||
variant="plain"
|
||||
size="plain"
|
||||
className="px-3 py-1 text-xs font-medium bg-muted text-foreground rounded-md hover:bg-muted/80 transition-colors"
|
||||
>
|
||||
Go to {parentApp?.displayName ?? "parent"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleEnable}
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
import { AppIcon } from "@/components/app-square";
|
||||
import { Badge, Button, Dialog, DialogContent, DialogTitle, ScrollArea, cn } from "@/components/ui";
|
||||
import { ALL_APPS_FRONTEND, type AppId } from "@/lib/apps-frontend";
|
||||
import { ArrowSquareOutIcon, CaretLeftIcon, CaretRightIcon, CheckIcon, LightningIcon, ShieldCheckIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { ALL_APPS_FRONTEND, isSubApp, type AppId } from "@/lib/apps-frontend";
|
||||
import { ArrowRightIcon, CaretLeftIcon, CaretRightIcon, CheckIcon, LightningIcon, ShieldCheckIcon, XIcon } from "@phosphor-icons/react";
|
||||
import { ALL_APPS, ALL_APP_TAGS } from "@stackframe/stack-shared/dist/apps/apps-config";
|
||||
import Image from "next/image";
|
||||
import { FunctionComponent, useCallback, useEffect, useRef, useState } from "react";
|
||||
@ -25,6 +25,8 @@ export function AppStoreEntry({
|
||||
}) {
|
||||
const app = ALL_APPS[appId];
|
||||
const appFrontend = ALL_APPS_FRONTEND[appId];
|
||||
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
|
||||
const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId];
|
||||
const screenshotContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
|
||||
|
||||
@ -142,36 +144,52 @@ export function AppStoreEntry({
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className="flex items-center gap-4">
|
||||
{isEnabled ? (
|
||||
<div className={cn("flex gap-4", parentApp == null ? "items-center" : "flex-col items-start")}>
|
||||
{parentApp == null ? (
|
||||
isEnabled ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={onOpen}
|
||||
size="lg"
|
||||
className="px-8 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
<ArrowRightIcon className="w-4 h-4 mr-2" />
|
||||
Open App
|
||||
</Button>
|
||||
{onDisable && (
|
||||
<Button
|
||||
onClick={onDisable}
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-950 dark:hover:text-red-300"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onEnable}
|
||||
size="lg"
|
||||
className="px-8 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
Enable App
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This app is part of the {parentApp.displayName} app.
|
||||
</p>
|
||||
<Button
|
||||
onClick={onOpen}
|
||||
size="lg"
|
||||
className="px-8 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
<ArrowSquareOutIcon className="w-4 h-4 mr-2" />
|
||||
Open App
|
||||
<ArrowRightIcon className="w-4 h-4 mr-2" />
|
||||
Go to {parentApp.displayName}
|
||||
</Button>
|
||||
{onDisable && (
|
||||
<Button
|
||||
onClick={onDisable}
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-950 dark:hover:text-red-300"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onEnable}
|
||||
size="lg"
|
||||
className="px-8 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
Enable App
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { AppIcon } from "@/components/app-square";
|
||||
import { Link } from "@/components/link";
|
||||
import { Badge, Button, ScrollArea } from "@/components/ui";
|
||||
import { ALL_APPS_FRONTEND, getAppPath, getItemPath } from "@/lib/apps-frontend";
|
||||
import { ALL_APPS_FRONTEND, getAppPath, getItemPath, hasNavigationItems, isSubApp, type NavigableAppFrontend } from "@/lib/apps-frontend";
|
||||
import { getUninstalledAppIds } from "@/lib/apps-utils";
|
||||
import { classifyClickHouseSqlVsPrompt } from "@/lib/classify-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CubeIcon, DownloadSimpleIcon, GearIcon, GlobeIcon, InfoIcon, KeyIcon, LayoutIcon, LightningIcon, PlayIcon, ShieldCheckIcon, SparkleIcon } from "@phosphor-icons/react";
|
||||
import { ChartBarIcon, CheckIcon, CubeIcon, DownloadSimpleIcon, EnvelopeSimpleIcon, GearIcon, GlobeIcon, HardDriveIcon, InfoIcon, KeyIcon, LayoutIcon, LightningIcon, Palette, PlayIcon, PlusIcon, ShieldCheckIcon, SparkleIcon, UsersIcon } from "@phosphor-icons/react";
|
||||
import { ALL_APPS, ALL_APP_TAGS, type AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
|
||||
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
|
||||
import Image from "next/image";
|
||||
@ -37,15 +38,19 @@ export type CmdKPreviewProps = {
|
||||
// Available App Preview Component - shows app store page in preview panel
|
||||
const AvailableAppPreview = memo(function AvailableAppPreview({
|
||||
appId,
|
||||
projectId,
|
||||
onEnable,
|
||||
goToParentHref,
|
||||
onClose,
|
||||
}: {
|
||||
appId: AppId,
|
||||
projectId: string,
|
||||
onEnable: () => Promise<void>,
|
||||
onEnable?: () => Promise<void>,
|
||||
goToParentHref?: string,
|
||||
onClose?: () => void,
|
||||
}) {
|
||||
const app = ALL_APPS[appId];
|
||||
const appFrontend = ALL_APPS_FRONTEND[appId];
|
||||
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
|
||||
const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId];
|
||||
|
||||
const features = [
|
||||
{ icon: ShieldCheckIcon, label: "Secure" },
|
||||
@ -119,18 +124,38 @@ const AvailableAppPreview = memo(function AvailableAppPreview({
|
||||
|
||||
{/* Enable Button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => runAsynchronouslyWithAlert(onEnable())}
|
||||
size="sm"
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium"
|
||||
>
|
||||
Enable App
|
||||
</Button>
|
||||
{parentApp == null ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (onEnable == null) return;
|
||||
runAsynchronouslyWithAlert(onEnable());
|
||||
}}
|
||||
size="sm"
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium"
|
||||
>
|
||||
Enable App
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="flex-1 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium"
|
||||
>
|
||||
<Link href={goToParentHref ?? "#"} onClick={onClose}>
|
||||
Go to {parentApp.displayName}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<InfoIcon className="w-3 h-3" />
|
||||
<span>Free</span>
|
||||
</div>
|
||||
</div>
|
||||
{parentApp != null && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
This app is part of the {parentApp.displayName} app.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Stage Warning */}
|
||||
{app.stage !== "stable" && (
|
||||
@ -188,20 +213,29 @@ const AvailableAppPreview = memo(function AvailableAppPreview({
|
||||
});
|
||||
|
||||
// Factory to create available app preview components
|
||||
function createAvailableAppPreview(appId: AppId, projectId: string, onEnable: () => Promise<void>): React.ComponentType<CmdKPreviewProps> {
|
||||
return function AvailableAppPreviewWrapper() {
|
||||
return <AvailableAppPreview appId={appId} projectId={projectId} onEnable={onEnable} />;
|
||||
function createAvailableAppPreview(
|
||||
appId: AppId,
|
||||
onEnable?: () => Promise<void>,
|
||||
goToParentHref?: string
|
||||
): React.ComponentType<CmdKPreviewProps> {
|
||||
return function AvailableAppPreviewWrapper({ onClose }: CmdKPreviewProps) {
|
||||
return <AvailableAppPreview appId={appId} onEnable={onEnable} goToParentHref={goToParentHref} onClose={onClose} />;
|
||||
};
|
||||
}
|
||||
|
||||
// Cache for available app preview components
|
||||
const availableAppPreviewCache = new Map<string, React.ComponentType<CmdKPreviewProps>>();
|
||||
|
||||
function getOrCreateAvailableAppPreview(appId: AppId, projectId: string, onEnable: () => Promise<void>): React.ComponentType<CmdKPreviewProps> {
|
||||
const cacheKey = `${appId}:${projectId}`;
|
||||
function getOrCreateAvailableAppPreview(
|
||||
appId: AppId,
|
||||
projectId: string,
|
||||
onEnable?: () => Promise<void>,
|
||||
goToParentHref?: string
|
||||
): React.ComponentType<CmdKPreviewProps> {
|
||||
const cacheKey = `${appId}:${projectId}:${goToParentHref ?? "enable"}:${onEnable == null ? "readonly" : "enable"}`;
|
||||
let preview = availableAppPreviewCache.get(cacheKey);
|
||||
if (!preview) {
|
||||
preview = createAvailableAppPreview(appId, projectId, onEnable);
|
||||
preview = createAvailableAppPreview(appId, onEnable, goToParentHref);
|
||||
availableAppPreviewCache.set(cacheKey, preview);
|
||||
}
|
||||
return preview;
|
||||
@ -229,11 +263,88 @@ export type CmdKCommand = {
|
||||
highlightColor?: string,
|
||||
};
|
||||
|
||||
type ProjectShortcutDefinition = {
|
||||
id: string,
|
||||
icon: React.FunctionComponent<React.SVGProps<SVGSVGElement>>,
|
||||
label: string,
|
||||
description: string,
|
||||
href: string,
|
||||
keywords: string[],
|
||||
requiredApps?: AppId[],
|
||||
};
|
||||
|
||||
const PROJECT_SHORTCUTS: ProjectShortcutDefinition[] = [
|
||||
{
|
||||
id: "navigation/users",
|
||||
icon: UsersIcon,
|
||||
label: "Users",
|
||||
description: "Navigation",
|
||||
href: "/users",
|
||||
keywords: ["users", "user", "people", "members", "accounts"],
|
||||
},
|
||||
{
|
||||
id: "navigation/dashboards",
|
||||
icon: ChartBarIcon,
|
||||
label: "Dashboards",
|
||||
description: "Navigation",
|
||||
href: "/dashboards",
|
||||
keywords: ["dashboards", "dashboard", "charts", "insights", "metrics"],
|
||||
},
|
||||
{
|
||||
id: "settings/trusted-domains",
|
||||
icon: GlobeIcon,
|
||||
label: "Trusted Domains",
|
||||
description: "Settings",
|
||||
href: "/domains",
|
||||
keywords: ["domains", "trusted domains", "custom domain", "handler", "allowlist"],
|
||||
requiredApps: ["authentication"],
|
||||
},
|
||||
{
|
||||
id: "emails/themes",
|
||||
icon: Palette,
|
||||
label: "Email Themes",
|
||||
description: "Emails",
|
||||
href: "/email-themes",
|
||||
keywords: ["email themes", "themes", "branding", "style", "templates"],
|
||||
requiredApps: ["emails"],
|
||||
},
|
||||
{
|
||||
id: "emails/outbox",
|
||||
icon: EnvelopeSimpleIcon,
|
||||
label: "Email Outbox",
|
||||
description: "Emails",
|
||||
href: "/email-outbox",
|
||||
keywords: ["email outbox", "outbox", "delivery", "queue", "scheduled emails"],
|
||||
requiredApps: ["emails"],
|
||||
},
|
||||
{
|
||||
id: "data-vault/stores",
|
||||
icon: HardDriveIcon,
|
||||
label: "Data Vault Stores",
|
||||
description: "Data Vault",
|
||||
href: "/data-vault/stores",
|
||||
keywords: ["data vault", "stores", "vault", "secrets", "encrypted storage"],
|
||||
requiredApps: ["data-vault"],
|
||||
},
|
||||
{
|
||||
id: "payments/new-product",
|
||||
icon: PlusIcon,
|
||||
label: "Create Product",
|
||||
description: "Payments",
|
||||
href: "/payments/products/new",
|
||||
keywords: ["create product", "new product", "payments", "pricing", "catalog"],
|
||||
requiredApps: ["payments"],
|
||||
},
|
||||
];
|
||||
|
||||
function toCommandIdSegment(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
// Factory to create app preview components that show navigation items
|
||||
function createAppPreview(appId: AppId, projectId: string): React.ComponentType<CmdKPreviewProps> {
|
||||
function createAppPreview(appId: AppId, projectId: string, appFrontend: NavigableAppFrontend): React.ComponentType<CmdKPreviewProps> {
|
||||
// Pre-compute these outside the component since they're static per appId
|
||||
const app = ALL_APPS[appId];
|
||||
const appFrontend = ALL_APPS_FRONTEND[appId];
|
||||
|
||||
// Pre-compute nested commands since they're static
|
||||
const IconComponent = appFrontend.icon;
|
||||
@ -273,7 +384,11 @@ function getOrCreateAppPreview(appId: AppId, projectId: string): React.Component
|
||||
const cacheKey = `${appId}:${projectId}`;
|
||||
let preview = appPreviewCache.get(cacheKey);
|
||||
if (!preview) {
|
||||
preview = createAppPreview(appId, projectId);
|
||||
const appFrontend = ALL_APPS_FRONTEND[appId];
|
||||
if (!hasNavigationItems(appFrontend)) {
|
||||
throw new Error(`App ${appId} has no navigation items`);
|
||||
}
|
||||
preview = createAppPreview(appId, projectId, appFrontend);
|
||||
appPreviewCache.set(cacheKey, preview);
|
||||
}
|
||||
return preview;
|
||||
@ -292,6 +407,21 @@ export function useCmdKCommands({
|
||||
}): CmdKCommand[] {
|
||||
return useMemo(() => {
|
||||
const commands: CmdKCommand[] = [];
|
||||
const pushUniqueNavigateCommand = (command: CmdKCommand) => {
|
||||
if (command.onAction.type !== "navigate") {
|
||||
commands.push(command);
|
||||
return;
|
||||
}
|
||||
|
||||
const href = command.onAction.href;
|
||||
const alreadyExists = commands.some((existingCommand) =>
|
||||
existingCommand.onAction.type === "navigate" &&
|
||||
existingCommand.onAction.href === href
|
||||
);
|
||||
if (!alreadyExists) {
|
||||
commands.push(command);
|
||||
}
|
||||
};
|
||||
const queryClassification = classifyClickHouseSqlVsPrompt(query, { readonlyOnly: true });
|
||||
const shouldPrioritizeRunQuery = queryClassification.kind === "sql";
|
||||
|
||||
@ -306,6 +436,24 @@ export function useCmdKCommands({
|
||||
preview: null,
|
||||
});
|
||||
|
||||
// Core navigation and power-tool shortcuts
|
||||
for (const shortcut of PROJECT_SHORTCUTS) {
|
||||
if (shortcut.requiredApps != null && !shortcut.requiredApps.every((appId) => enabledApps.includes(appId))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const IconComponent = shortcut.icon;
|
||||
pushUniqueNavigateCommand({
|
||||
id: shortcut.id,
|
||||
icon: <IconComponent className="h-3.5 w-3.5 text-muted-foreground" />,
|
||||
label: shortcut.label,
|
||||
description: shortcut.description,
|
||||
keywords: shortcut.keywords,
|
||||
onAction: { type: "navigate", href: `/projects/${projectId}${shortcut.href}` },
|
||||
preview: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Installed apps - with preview for navigation items
|
||||
for (const appId of enabledApps) {
|
||||
const app = ALL_APPS[appId];
|
||||
@ -313,21 +461,77 @@ export function useCmdKCommands({
|
||||
// Some enabled apps might not have navigation metadata yet
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!app || !appFrontend) continue;
|
||||
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
|
||||
const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId];
|
||||
|
||||
const IconComponent = appFrontend.icon;
|
||||
const hasNavigationItems = appFrontend.navigationItems.length > 0;
|
||||
if (!hasNavigationItems(appFrontend)) {
|
||||
commands.push({
|
||||
id: `apps/${appId}`,
|
||||
icon: <IconComponent className="h-3.5 w-3.5 stroke-emerald-600 dark:stroke-emerald-400" />,
|
||||
label: app.displayName,
|
||||
description: parentApp == null ? "Installed app" : `Part of ${parentApp.displayName}`,
|
||||
keywords: [
|
||||
app.displayName.toLowerCase(),
|
||||
app.subtitle.toLowerCase(),
|
||||
appId,
|
||||
appFrontend.href.toLowerCase(),
|
||||
appFrontend.href.toLowerCase().replace(/-/g, " "),
|
||||
...app.tags,
|
||||
"installed",
|
||||
"app",
|
||||
...(parentApp == null ? [] : [parentApp.displayName.toLowerCase(), "sub-app"]),
|
||||
],
|
||||
onAction: { type: "navigate", href: getAppPath(projectId, appFrontend) },
|
||||
preview: null,
|
||||
highlightColor: "app",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add the app itself as a command
|
||||
const hasNestedNavigation = appFrontend.navigationItems.length > 0;
|
||||
commands.push({
|
||||
id: `apps/${appId}`,
|
||||
icon: <IconComponent className="h-3.5 w-3.5 stroke-emerald-600 dark:stroke-emerald-400" />,
|
||||
label: app.displayName,
|
||||
description: "Installed app",
|
||||
keywords: [app.displayName.toLowerCase(), ...app.tags, "installed", "app"],
|
||||
description: parentApp == null ? "Installed app" : `Part of ${parentApp.displayName}`,
|
||||
keywords: [
|
||||
app.displayName.toLowerCase(),
|
||||
app.subtitle.toLowerCase(),
|
||||
appId,
|
||||
appFrontend.href.toLowerCase(),
|
||||
appFrontend.href.toLowerCase().replace(/-/g, " "),
|
||||
...app.tags,
|
||||
"installed",
|
||||
"app",
|
||||
...(parentApp == null ? [] : [parentApp.displayName.toLowerCase(), "sub-app"]),
|
||||
],
|
||||
onAction: { type: "navigate", href: getAppPath(projectId, appFrontend) },
|
||||
preview: hasNavigationItems ? getOrCreateAppPreview(appId, projectId) : null,
|
||||
preview: hasNestedNavigation ? getOrCreateAppPreview(appId, projectId) : null,
|
||||
highlightColor: "app",
|
||||
});
|
||||
|
||||
// Flatten app pages so they're directly searchable without nesting
|
||||
for (const navItem of appFrontend.navigationItems) {
|
||||
const itemPath = getItemPath(projectId, appFrontend, navItem);
|
||||
pushUniqueNavigateCommand({
|
||||
id: `apps/${appId}/page/${toCommandIdSegment(navItem.displayName)}`,
|
||||
icon: <IconComponent className="h-3.5 w-3.5 text-muted-foreground" />,
|
||||
label: `${app.displayName}: ${navItem.displayName}`,
|
||||
description: `Page in ${app.displayName}`,
|
||||
keywords: [
|
||||
app.displayName.toLowerCase(),
|
||||
navItem.displayName.toLowerCase(),
|
||||
`${app.displayName.toLowerCase()} ${navItem.displayName.toLowerCase()}`,
|
||||
appId,
|
||||
"page",
|
||||
"navigate",
|
||||
],
|
||||
onAction: { type: "navigate", href: itemPath },
|
||||
preview: null,
|
||||
highlightColor: "app",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Available (uninstalled) apps
|
||||
@ -338,6 +542,15 @@ export function useCmdKCommands({
|
||||
// Some apps might not have frontend metadata yet
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!app || !appFrontend) continue;
|
||||
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
|
||||
const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId];
|
||||
const parentAppFrontend = parentAppId == null ? null : ALL_APPS_FRONTEND[parentAppId];
|
||||
const isParentEnabled = parentAppId == null ? false : enabledApps.includes(parentAppId);
|
||||
const parentDestination = parentAppId == null || parentAppFrontend == null
|
||||
? null
|
||||
: isParentEnabled
|
||||
? getAppPath(projectId, appFrontend)
|
||||
: `/projects/${projectId}/apps/${parentAppId}`;
|
||||
|
||||
const IconComponent = appFrontend.icon;
|
||||
const hasPreview = onEnableApp !== undefined;
|
||||
@ -351,15 +564,32 @@ export function useCmdKCommands({
|
||||
</div>
|
||||
),
|
||||
label: app.displayName,
|
||||
description: "Available to install",
|
||||
keywords: [app.displayName.toLowerCase(), ...app.tags, "available", "install", "store", "app"],
|
||||
onAction: hasPreview
|
||||
? { type: "focus" }
|
||||
: { type: "navigate", href: `/projects/${projectId}/apps/${appId}` },
|
||||
preview: hasPreview
|
||||
? getOrCreateAvailableAppPreview(appId, projectId, () => onEnableApp(appId))
|
||||
description: parentApp == null ? "Available to install" : `Part of ${parentApp.displayName}`,
|
||||
keywords: [
|
||||
app.displayName.toLowerCase(),
|
||||
app.subtitle.toLowerCase(),
|
||||
appId,
|
||||
...app.tags,
|
||||
"available",
|
||||
"install",
|
||||
"store",
|
||||
"app",
|
||||
...(parentApp == null ? [] : ["sub-app", parentApp.displayName.toLowerCase()]),
|
||||
],
|
||||
onAction: parentApp == null
|
||||
? hasPreview
|
||||
? { type: "focus" }
|
||||
: { type: "navigate", href: `/projects/${projectId}/apps/${appId}` }
|
||||
: { type: "navigate", href: parentDestination ?? `/projects/${projectId}/apps/${appId}` },
|
||||
preview: parentApp == null && hasPreview
|
||||
? getOrCreateAvailableAppPreview(
|
||||
appId,
|
||||
projectId,
|
||||
() => onEnableApp(appId),
|
||||
undefined
|
||||
)
|
||||
: null,
|
||||
hasVisualPreview: hasPreview,
|
||||
hasVisualPreview: parentApp == null && hasPreview,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { BUNDLED_DASHBOARD_UI_TYPES, BUNDLED_TYPE_DEFINITIONS } from "@/generated/bundled-type-definitions";
|
||||
import { ALL_APPS_FRONTEND, type AppId, getItemPath } from "@/lib/apps-frontend";
|
||||
import { ALL_APPS_FRONTEND, type AppId, getItemPath, hasNavigationItems } from "@/lib/apps-frontend";
|
||||
import { buildStackAuthHeaders, type CurrentUser } from "@/lib/api-headers";
|
||||
|
||||
/**
|
||||
@ -19,6 +19,9 @@ export function buildAvailableRoutes(enabledAppIds: AppId[]): string {
|
||||
// Dynamic routes from enabled apps
|
||||
for (const appId of enabledAppIds) {
|
||||
const appFrontend = ALL_APPS_FRONTEND[appId as keyof typeof ALL_APPS_FRONTEND];
|
||||
if (!hasNavigationItems(appFrontend)) {
|
||||
continue;
|
||||
}
|
||||
for (const item of appFrontend.navigationItems) {
|
||||
// Use a placeholder project ID — we only need the path relative to /projects/[id]/
|
||||
const fullPath = getItemPath("__PROJECT__", appFrontend, item);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Link } from "@/components/link";
|
||||
import { ChartLineIcon, ClipboardTextIcon, CreditCardIcon, EnvelopeSimpleIcon, FingerprintSimpleIcon, KeyIcon, MailboxIcon, RocketIcon, SparkleIcon, TelevisionSimpleIcon, TriangleIcon, UserGearIcon, UsersIcon, VaultIcon, WebhooksLogoIcon } from "@phosphor-icons/react";
|
||||
import { ChartLineIcon, ClipboardTextIcon, CreditCardIcon, EnvelopeSimpleIcon, FingerprintSimpleIcon, KeyIcon, MailboxIcon, RocketIcon, ShieldCheckIcon, SparkleIcon, TelevisionSimpleIcon, TriangleIcon, UserGearIcon, UsersIcon, VaultIcon, WebhooksLogoIcon } from "@phosphor-icons/react";
|
||||
import { StackAdminApp } from "@stackframe/stack";
|
||||
import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config";
|
||||
import { getRelativePart, isChildUrl } from "@stackframe/stack-shared/dist/utils/urls";
|
||||
@ -33,34 +33,55 @@ export type AppFrontend = {
|
||||
icon: React.FunctionComponent<React.SVGProps<SVGSVGElement>>,
|
||||
logo?: React.FunctionComponent<{}>,
|
||||
href: string,
|
||||
matchPath?: (relativePart: string) => boolean,
|
||||
getBreadcrumbItems?: (stackAdminApp: StackAdminApp<false>, relativePart: string) => Promise<BreadcrumbDefinition | null | undefined>,
|
||||
navigationItems: AppNavigationItem[],
|
||||
screenshots: (string | StaticImageData)[],
|
||||
storeDescription: JSX.Element,
|
||||
};
|
||||
} & (
|
||||
| {
|
||||
navigationItems: AppNavigationItem[],
|
||||
matchPath?: (relativePart: string) => boolean,
|
||||
getBreadcrumbItems?: (stackAdminApp: StackAdminApp<false>, relativePart: string) => Promise<BreadcrumbDefinition | null | undefined>,
|
||||
}
|
||||
| {
|
||||
parentAppId: AppId,
|
||||
}
|
||||
)
|
||||
|
||||
export type NavigableAppFrontend = Extract<AppFrontend, { navigationItems: AppNavigationItem[] }>;
|
||||
export type SubAppFrontend = Extract<AppFrontend, { parentAppId: AppId }>;
|
||||
|
||||
export function hasNavigationItems(appFrontend: AppFrontend): appFrontend is NavigableAppFrontend {
|
||||
return "navigationItems" in appFrontend;
|
||||
}
|
||||
|
||||
export function isSubApp(appFrontend: AppFrontend): appFrontend is SubAppFrontend {
|
||||
return "parentAppId" in appFrontend;
|
||||
}
|
||||
|
||||
export function getAppPath(projectId: string, appFrontend: AppFrontend) {
|
||||
const url = new URL(appFrontend.href, `${DUMMY_ORIGIN}/projects/${projectId}/`);
|
||||
return getRelativePart(url);
|
||||
}
|
||||
|
||||
export function getItemPath(projectId: string, appFrontend: AppFrontend, item: AppFrontend["navigationItems"][number]) {
|
||||
export function getItemPath(projectId: string, appFrontend: NavigableAppFrontend, item: AppNavigationItem) {
|
||||
const url = new URL(item.href, new URL(appFrontend.href, `${DUMMY_ORIGIN}/projects/${projectId}/`) + "/");
|
||||
return getRelativePart(url);
|
||||
}
|
||||
|
||||
export function testAppPath(projectId: string, appFrontend: AppFrontend, fullUrl: URL) {
|
||||
if (appFrontend.matchPath) return appFrontend.matchPath(getRelativePart(fullUrl));
|
||||
if ("matchPath" in appFrontend && appFrontend.matchPath) {
|
||||
return appFrontend.matchPath(getRelativePart(fullUrl));
|
||||
}
|
||||
|
||||
for (const item of appFrontend.navigationItems) {
|
||||
if (testItemPath(projectId, appFrontend, item, fullUrl)) return true;
|
||||
if (hasNavigationItems(appFrontend)) {
|
||||
for (const item of appFrontend.navigationItems) {
|
||||
if (testItemPath(projectId, appFrontend, item, fullUrl)) return true;
|
||||
}
|
||||
}
|
||||
const url = new URL(appFrontend.href, `${DUMMY_ORIGIN}/projects/${projectId}/`);
|
||||
return isChildUrl(url, fullUrl);
|
||||
}
|
||||
|
||||
export function testItemPath(projectId: string, appFrontend: AppFrontend, item: AppFrontend["navigationItems"][number], fullUrl: URL) {
|
||||
export function testItemPath(projectId: string, appFrontend: NavigableAppFrontend, item: AppNavigationItem, fullUrl: URL) {
|
||||
if (item.matchPath) return item.matchPath(getRelativePart(fullUrl));
|
||||
|
||||
const url = new URL(getItemPath(projectId, appFrontend, item), fullUrl);
|
||||
@ -84,6 +105,16 @@ export const ALL_APPS_FRONTEND = {
|
||||
</>
|
||||
),
|
||||
},
|
||||
"fraud-protection": {
|
||||
icon: ShieldCheckIcon,
|
||||
href: "sign-up-rules",
|
||||
parentAppId: "authentication",
|
||||
screenshots: [],
|
||||
storeDescription: <>
|
||||
<p>Fraud Protection helps you protect your project from fraud and abuse.</p>
|
||||
<p>Configure sign-up rules and use our built-in fraud protection features to detect bots, free trial abuse, and other fraudulent activity.</p>
|
||||
</>,
|
||||
},
|
||||
onboarding: {
|
||||
icon: ClipboardTextIcon,
|
||||
href: "onboarding",
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { ALL_APPS_FRONTEND, hasNavigationItems, isSubApp } from "@/lib/apps-frontend";
|
||||
import { ALL_APPS, type AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
|
||||
|
||||
type InstalledAppConfig = {
|
||||
enabled?: boolean,
|
||||
} | undefined;
|
||||
|
||||
export type InstalledAppsMap = Record<string, InstalledAppConfig>;
|
||||
|
||||
/**
|
||||
* Get all available app IDs, filtering out alpha apps in production
|
||||
*/
|
||||
@ -16,6 +23,33 @@ export function getAllAvailableAppIds(): AppId[] {
|
||||
return apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether an app is enabled.
|
||||
* - Regular apps are enabled via their own config entry.
|
||||
* - Sub-apps are enabled when their parent app is enabled.
|
||||
*/
|
||||
export function isAppEnabled(installedApps: InstalledAppsMap, appId: AppId): boolean {
|
||||
const appFrontend = ALL_APPS_FRONTEND[appId];
|
||||
if (isSubApp(appFrontend)) {
|
||||
return installedApps[appFrontend.parentAppId]?.enabled ?? false;
|
||||
}
|
||||
return installedApps[appId]?.enabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all enabled app IDs using centralized enabled/sub-app logic.
|
||||
*/
|
||||
export function getEnabledAppIds(installedApps: InstalledAppsMap): AppId[] {
|
||||
return getAllAvailableAppIds().filter((appId) => isAppEnabled(installedApps, appId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enabled apps that expose sidebar/cmdk navigation items.
|
||||
*/
|
||||
export function getEnabledNavigableAppIds(installedApps: InstalledAppsMap): AppId[] {
|
||||
return getEnabledAppIds(installedApps).filter((appId) => hasNavigationItems(ALL_APPS_FRONTEND[appId]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get uninstalled app IDs (available but not installed)
|
||||
*/
|
||||
|
||||
@ -8,6 +8,7 @@ import { describe, beforeAll, afterAll } from "vitest";
|
||||
import { it, niceFetch, STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_SERVER_KEY, STACK_INTERNAL_PROJECT_ADMIN_KEY } from "../helpers";
|
||||
|
||||
const CLI_BIN = path.resolve("packages/stack-cli/dist/index.js");
|
||||
const CLI_SRC_BIN = path.resolve("packages/stack-cli/src/index.ts");
|
||||
|
||||
function runCli(
|
||||
args: string[],
|
||||
@ -464,3 +465,72 @@ describe("Stack CLI", () => {
|
||||
expect(stdout).toContain("STACK AUTH SETUP INSTRUCTIONS");
|
||||
});
|
||||
});
|
||||
|
||||
// Emulator CLI tests — no backend required, just validates help/arg parsing
|
||||
describe("Stack CLI — Emulator", () => {
|
||||
function runCliBare(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string, stderr: string, exitCode: number | null }> {
|
||||
return new Promise((resolve) => {
|
||||
execFile("node", [CLI_BIN, ...args], {
|
||||
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", CI: "1" },
|
||||
timeout: 15_000,
|
||||
}, (error, stdout, stderr) => {
|
||||
resolve({
|
||||
stdout: stdout.toString(),
|
||||
stderr: stderr.toString(),
|
||||
exitCode: error ? (error as any).code ?? 1 : 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function runCliBareFromSource(
|
||||
args: string[],
|
||||
): Promise<{ stdout: string, stderr: string, exitCode: number | null }> {
|
||||
return new Promise((resolve) => {
|
||||
execFile("node", ["--import", "tsx", CLI_SRC_BIN, ...args], {
|
||||
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", CI: "1" },
|
||||
timeout: 15_000,
|
||||
}, (error, stdout, stderr) => {
|
||||
resolve({
|
||||
stdout: stdout.toString(),
|
||||
stderr: stderr.toString(),
|
||||
exitCode: error ? (error as any).code ?? 1 : 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("emulator help shows subcommands", async ({ expect }) => {
|
||||
const { stdout, exitCode } = await runCliBare(["emulator", "--help"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("pull");
|
||||
expect(stdout).toContain("start");
|
||||
expect(stdout).toContain("stop");
|
||||
expect(stdout).toContain("reset");
|
||||
expect(stdout).toContain("status");
|
||||
expect(stdout).toContain("list-releases");
|
||||
});
|
||||
|
||||
it("emulator pull help shows options", async ({ expect }) => {
|
||||
const { stdout, exitCode } = await runCliBare(["emulator", "pull", "--help"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("--arch");
|
||||
expect(stdout).toContain("--branch");
|
||||
expect(stdout).toContain("--tag");
|
||||
expect(stdout).toContain("--repo");
|
||||
});
|
||||
|
||||
it("emulator pull rejects invalid arch values", async ({ expect }) => {
|
||||
const { stderr, exitCode } = await runCliBareFromSource(["emulator", "pull", "--arch", "sparc"]);
|
||||
expect(exitCode).toBe(1);
|
||||
expect(stderr).toContain("Invalid architecture: sparc. Expected arm64 or amd64.");
|
||||
});
|
||||
|
||||
it("emulator list-releases help shows repo option", async ({ expect }) => {
|
||||
const { stdout, exitCode } = await runCliBare(["emulator", "list-releases", "--help"]);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("--repo");
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { StackClientApp } from "@stackframe/js";
|
||||
import { encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes";
|
||||
import { TextEncoder } from "util";
|
||||
import { vi } from "vitest";
|
||||
import { STACK_BACKEND_BASE_URL } from "../helpers";
|
||||
import { it } from "../helpers";
|
||||
import { createApp } from "./js-helpers";
|
||||
|
||||
@ -302,6 +304,34 @@ it("should omit secure-only defaults when running on http origins", async ({ exp
|
||||
expect(insecureAttrs?.get("domain")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should roundtrip domain through custom refresh cookie name encode/decode", async ({ expect }) => {
|
||||
const { clientApp } = await createApp();
|
||||
|
||||
const domains = [
|
||||
"example.com",
|
||||
"sub.example.com",
|
||||
"deep.nested.example.com",
|
||||
"EXAMPLE.COM",
|
||||
"my-site.co.uk",
|
||||
];
|
||||
|
||||
for (const domain of domains) {
|
||||
const cookieName = (clientApp as any)._getCustomRefreshCookieName(domain);
|
||||
const decoded = (clientApp as any)._getDomainFromCustomRefreshCookieName(cookieName);
|
||||
expect(decoded).toBe(domain.toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
it("should return null for non-custom refresh cookie names", async ({ expect }) => {
|
||||
const { clientApp } = await createApp();
|
||||
|
||||
const defaultName = getDefaultRefreshCookieName(clientApp.projectId, true);
|
||||
expect((clientApp as any)._getDomainFromCustomRefreshCookieName(defaultName)).toBeNull();
|
||||
expect((clientApp as any)._getDomainFromCustomRefreshCookieName("unrelated-cookie")).toBeNull();
|
||||
expect((clientApp as any)._getDomainFromCustomRefreshCookieName("")).toBeNull();
|
||||
expect((clientApp as any)._getDomainFromCustomRefreshCookieName(`stack-refresh-${clientApp.projectId}--custom-%%%`)).toBeNull();
|
||||
});
|
||||
|
||||
it("should read the newest refresh token payload from cookie storage", async ({ expect }) => {
|
||||
const { clientApp } = await createApp();
|
||||
|
||||
@ -327,3 +357,72 @@ it("should read the newest refresh token payload from cookie storage", async ({
|
||||
expect(tokens.refreshToken).toBe("fresh-token");
|
||||
expect(tokens.accessToken).toBe("fresh-access-token");
|
||||
});
|
||||
|
||||
it("should eagerly create cross-subdomain cookie on construction when session exists but custom cookie is missing", async ({ expect }) => {
|
||||
const { cookieStore } = setupBrowserCookieEnv({ protocol: "https:" });
|
||||
|
||||
const { clientApp, apiKey } = await createApp(
|
||||
{
|
||||
config: {
|
||||
domains: [
|
||||
{ domain: "https://example.com", handlerPath: "/handler" },
|
||||
{ domain: "https://**.example.com", handlerPath: "/handler" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
client: {
|
||||
tokenStore: "cookie",
|
||||
noAutomaticPrefetch: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Sign in to get a valid session
|
||||
const email = `${crypto.randomUUID()}@eager-cookie.test`;
|
||||
const password = "password";
|
||||
await clientApp.signUpWithCredential({ email, password, verificationCallbackUrl: "http://localhost:3000", noRedirect: true });
|
||||
await clientApp.signInWithCredential({ email, password, noRedirect: true });
|
||||
|
||||
const defaultCookieName = getDefaultRefreshCookieName(clientApp.projectId, true);
|
||||
const customCookieName = getCustomRefreshCookieName(clientApp.projectId, "example.com");
|
||||
|
||||
// Wait for the cross-subdomain cookie to be written
|
||||
const customReady = await waitUntil(() => cookieStore.has(customCookieName), 10_000);
|
||||
expect(customReady).toBe(true);
|
||||
|
||||
// Grab the refresh token before we manipulate cookies
|
||||
const customCookieValue = cookieStore.get(customCookieName)!;
|
||||
const parsed = JSON.parse(decodeURIComponent(customCookieValue));
|
||||
|
||||
// Simulate state where user was signed in before wildcard domain was added:
|
||||
// default cookie exists with the session, but no cross-subdomain cookie
|
||||
cookieStore.delete(customCookieName);
|
||||
const defaultValue = encodeURIComponent(JSON.stringify({
|
||||
refresh_token: parsed.refresh_token,
|
||||
updated_at_millis: parsed.updated_at_millis,
|
||||
}));
|
||||
cookieStore.set(defaultCookieName, defaultValue);
|
||||
|
||||
expect(cookieStore.has(customCookieName)).toBe(false);
|
||||
expect(cookieStore.has(defaultCookieName)).toBe(true);
|
||||
|
||||
// Construct a new client app (simulates page reload)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const reloadedApp = new StackClientApp({
|
||||
baseUrl: STACK_BACKEND_BASE_URL,
|
||||
projectId: clientApp.projectId,
|
||||
publishableClientKey: apiKey.publishableClientKey,
|
||||
tokenStore: "cookie",
|
||||
redirectMethod: "none",
|
||||
noAutomaticPrefetch: true,
|
||||
extraRequestHeaders: { "x-stack-disable-artificial-development-delay": "yes" },
|
||||
});
|
||||
|
||||
// The cross-subdomain cookie should be eagerly created on construction
|
||||
const customRecreated = await waitUntil(() => cookieStore.has(customCookieName), 10_000);
|
||||
expect(customRecreated).toBe(true);
|
||||
|
||||
// Clean up
|
||||
(reloadedApp as any).dispose?.();
|
||||
});
|
||||
|
||||
@ -141,6 +141,8 @@ A: Only write `onboardingStatus` when the `Project.onboardingStatus` column exis
|
||||
|
||||
Q: How does the Stack Auth docs MCP relate to the ask-chat API and doc tools?
|
||||
A: The public MCP (`/api/internal/mcp` on the docs site) exposes only `ask_stack_auth`, which POSTs to `/api/latest/ai/query/generate` with `tools: ["docs"]` and `systemPrompt: "docs-ask-ai"`. The backend no longer loads doc tools via MCP; `createDocsTools()` calls the docs app `POST /api/internal/docs-tools` with typed actions (same behavior as before). Optional `STACK_INTERNAL_DOCS_TOOLS_SECRET` gates the internal route; `STACK_DOCS_INTERNAL_BASE_URL` overrides the docs origin for the backend.
|
||||
Q: What caused the March 19, 2026 QEMU local emulator deps startup regression?
|
||||
A: The QEMU runtime path regressed when it switched from mounting `docker/local-emulator/base.env` into the runtime ISO to mounting the generated hidden file `docker/local-emulator/.env.development` instead. In testing, the `.env.development` QEMU path left cold boot stuck with only PostgreSQL healthy, while restoring the runtime ISO back to `base.env` brought deps startup back to about 12-13 seconds. The env payloads were effectively the same, so the likely issue was the QEMU runtime bundle/path handling for `.env.development`, not the actual env values.
|
||||
Q: Where is the private sign-up risk engine generated entrypoint in backend now?
|
||||
A: The generator script writes `apps/backend/src/private/implementation.generated.ts` (not `src/generated/private-sign-up-risk-engine.ts`), and backend runtime imports should target `@/private/implementation.generated`.
|
||||
|
||||
@ -158,3 +160,6 @@ A: `app.urls` is now static (`getUrls(...)` only) and no longer injects runtime
|
||||
|
||||
Q: How should user signup time be exposed in JWT claims before production rollout?
|
||||
A: Use `signed_up_at` (OIDC-style naming) in access tokens and encode it as Unix seconds in `apps/backend/src/lib/tokens.tsx` (`Math.floor(user.signed_up_at_millis / 1000)`). Since this is pre-prod, the payload schema can require `signed_up_at` directly without a backward-compat optional shim.
|
||||
|
||||
Q: Where should new globally searchable Cmd+K destinations be added in the dashboard?
|
||||
A: Add project-level shortcuts to `PROJECT_SHORTCUTS` in `apps/dashboard/src/components/cmdk-commands.tsx` (optionally gated with `requiredApps`), and for app subpages rely on the flattened `appFrontend.navigationItems` command generation in the same file so pages are directly searchable without nested preview navigation.
|
||||
|
||||
199
docker/local-emulator/Dockerfile
Normal file
199
docker/local-emulator/Dockerfile
Normal file
@ -0,0 +1,199 @@
|
||||
# Stack Auth Local Emulator — All-in-One Image
|
||||
# Packages: PostgreSQL 16, Redis 7, Inbucket, Svix, ClickHouse, MinIO, QStash
|
||||
# + built Stack Auth backend and dashboard
|
||||
|
||||
ARG NODE_VERSION=22.21.1
|
||||
|
||||
# ── Node.js build stages ──────────────────────────────────────────────────────
|
||||
|
||||
FROM node:${NODE_VERSION} AS node-base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y && \
|
||||
rm -rf /var/lib/apt/lists
|
||||
|
||||
ENV PNPM_HOME=/pnpm
|
||||
ENV PATH=$PNPM_HOME:$PATH
|
||||
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@10.23.0 --activate
|
||||
RUN pnpm add -g turbo
|
||||
RUN pnpm add -g tsx
|
||||
|
||||
|
||||
FROM node-base AS pruner
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN tsx ./scripts/generate-sdks.ts
|
||||
|
||||
# https://turbo.build/repo/docs/guides/tools/docker
|
||||
RUN turbo prune --scope=@stackframe/backend --scope=@stackframe/dashboard --docker
|
||||
|
||||
|
||||
FROM node-base AS builder
|
||||
|
||||
# copy over package.json files and install dependencies
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-lock.yaml .
|
||||
COPY .gitignore .
|
||||
COPY pnpm-workspace.yaml .
|
||||
COPY turbo.json .
|
||||
COPY configs ./configs
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store STACK_SKIP_TEMPLATE_GENERATION=true pnpm install --frozen-lockfile
|
||||
|
||||
# copy over the rest of the code for the build
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
# docs are currently required for the NextJS backend build, but won't exist in the final image
|
||||
COPY docs ./docs
|
||||
|
||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
ENV NEXT_CONFIG_OUTPUT=standalone
|
||||
|
||||
# Build the backend NextJS app
|
||||
RUN pnpm turbo run docker-build --filter=@stackframe/backend... --filter=@stackframe/dashboard...
|
||||
|
||||
# Build the self-host seed script
|
||||
RUN cd apps/backend && pnpm build-self-host-migration-script
|
||||
|
||||
|
||||
# Prune node_modules for runtime: remove dev tools, heavy UI packages,
|
||||
# duplicate framework copies, and native binaries not needed by the
|
||||
# migration script or server at runtime.
|
||||
FROM builder AS migration-pruner
|
||||
RUN cp -a /app/node_modules /pruned-node_modules && \
|
||||
cd /pruned-node_modules/.pnpm && \
|
||||
rm -rf \
|
||||
# Dev tools (never needed at runtime)
|
||||
typescript@* eslint@* eslint-*@* @typescript-eslint+*@* \
|
||||
prettier@* vitest@* jsdom@* turbo@* turbo-*@* \
|
||||
tsdown@* @changesets+*@* codebuff@* \
|
||||
@testing-library+*@* vite@* vite-*@* @vitejs+*@* \
|
||||
# Heavy UI packages (already traced into Next.js standalone bundles)
|
||||
monaco-editor@* \
|
||||
three@* three-globe@* globe.gl@* react-globe*@* \
|
||||
react-icons@* lucide-react@* @phosphor-icons+*@* \
|
||||
# Large optional packages not needed by migration script
|
||||
posthog-js@* \
|
||||
@prisma+studio-core@* @prisma+dev@* @prisma+query-plan-executor@* \
|
||||
convex@* @electric-sql+*@* \
|
||||
# Duplicate Next.js copies (keep only one for next/headers.js resolution)
|
||||
'next@16.1.5_@babel+core@7.29.0*' 'next@16.1.5_@babel+core@7.28.5*' \
|
||||
next@14* @next+swc-*@14* \
|
||||
# Native build binaries not needed at runtime
|
||||
@esbuild+*@* esbuild@* @rolldown+*@* \
|
||||
# Duplicate date-fns versions (keep v4 only)
|
||||
date-fns@2* date-fns@3*
|
||||
|
||||
|
||||
# ── Service binary stages ─────────────────────────────────────────────────────
|
||||
|
||||
FROM inbucket/inbucket:3.1.0 AS inbucket-bin
|
||||
FROM svix/svix-server:v1.88.0 AS svix-bin
|
||||
FROM clickhouse/clickhouse-server:25.10 AS clickhouse-bin
|
||||
FROM minio/minio:RELEASE.2025-09-07T16-13-09Z AS minio-bin
|
||||
FROM minio/mc:RELEASE.2025-02-21T16-00-46Z AS mc-bin
|
||||
|
||||
FROM bgodil/qstash:latest AS qstash-bin
|
||||
RUN cp $(which qstash) /qstash-binary 2>/dev/null || \
|
||||
cp $(find / -name 'qstash' -type f -executable 2>/dev/null | head -1) /qstash-binary || \
|
||||
{ echo "ERROR: qstash binary not found" >&2; exit 1; }
|
||||
|
||||
|
||||
# ── Final image ───────────────────────────────────────────────────────────────
|
||||
|
||||
FROM debian:trixie-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
gnupg2 \
|
||||
lsb-release \
|
||||
curl \
|
||||
ca-certificates \
|
||||
&& echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
|
||||
> /etc/apt/sources.list.d/pgdg.list \
|
||||
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
|
||||
| gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
postgresql-16 \
|
||||
postgresql-client-16 \
|
||||
redis-server \
|
||||
supervisor \
|
||||
gosu \
|
||||
procps \
|
||||
libssl3 \
|
||||
openssl \
|
||||
socat \
|
||||
&& apt-get purge -y --auto-remove gnupg2 lsb-release \
|
||||
&& rm -rf /var/lib/apt/lists/* /usr/share/doc /usr/share/man /usr/share/i18n
|
||||
|
||||
# Node.js runtime (binary only — app bundles include all JS dependencies)
|
||||
COPY --from=node-base /usr/local/bin/node /usr/local/bin/node
|
||||
|
||||
# Inbucket
|
||||
COPY --from=inbucket-bin /opt/inbucket /opt/inbucket
|
||||
|
||||
# Svix
|
||||
COPY --from=svix-bin /usr/local/bin/svix-server /usr/local/bin/svix-server
|
||||
|
||||
# ClickHouse
|
||||
COPY --from=clickhouse-bin /usr/bin/clickhouse /usr/bin/clickhouse
|
||||
RUN ln -sf /usr/bin/clickhouse /usr/bin/clickhouse-server && \
|
||||
ln -sf /usr/bin/clickhouse /usr/bin/clickhouse-client
|
||||
|
||||
# MinIO
|
||||
COPY --from=minio-bin /usr/bin/minio /usr/local/bin/minio
|
||||
COPY --from=mc-bin /usr/bin/mc /usr/local/bin/mc
|
||||
|
||||
# QStash
|
||||
COPY --from=qstash-bin --chmod=755 /qstash-binary /usr/local/bin/qstash
|
||||
|
||||
# App
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/apps/backend/.next/standalone ./
|
||||
COPY --from=builder /app/apps/backend/.next/static ./apps/backend/.next/static
|
||||
COPY --from=builder /app/apps/backend/prisma ./apps/backend/prisma
|
||||
COPY --from=builder /app/apps/backend/dist ./apps/backend/dist
|
||||
COPY --from=builder /app/apps/backend/node_modules ./apps/backend/node_modules
|
||||
COPY --from=builder /app/apps/dashboard/.next/standalone ./
|
||||
COPY --from=builder /app/apps/dashboard/.next/static ./apps/dashboard/.next/static
|
||||
COPY --from=builder /app/apps/dashboard/public ./apps/dashboard/public
|
||||
COPY --from=migration-pruner /pruned-node_modules ./node_modules
|
||||
COPY --from=builder /app/packages ./packages
|
||||
|
||||
RUN mkdir -p \
|
||||
/data/postgres \
|
||||
/data/redis \
|
||||
/data/clickhouse \
|
||||
/data/clickhouse/access \
|
||||
/data/clickhouse/tmp \
|
||||
/data/clickhouse/user_files \
|
||||
/data/clickhouse/format_schemas \
|
||||
/data/minio \
|
||||
/data/inbucket \
|
||||
/var/log/supervisor \
|
||||
/var/log/clickhouse \
|
||||
/etc/clickhouse-server \
|
||||
&& chown -R postgres:postgres /data/postgres
|
||||
|
||||
COPY docker/local-emulator/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
COPY docker/local-emulator/entrypoint.sh /entrypoint.sh
|
||||
COPY docker/local-emulator/init-services.sh /init-services.sh
|
||||
COPY docker/local-emulator/start-app.sh /start-app.sh
|
||||
COPY docker/local-emulator/clickhouse-config.xml /etc/clickhouse-server/config.xml
|
||||
COPY docker/local-emulator/clickhouse-users.xml /etc/clickhouse-server/users.xml
|
||||
COPY docker/server/entrypoint.sh /app-entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh /init-services.sh /start-app.sh /app-entrypoint.sh
|
||||
|
||||
# PostgreSQL: 5432, Redis: 6379, Inbucket: 2500/9001/1100,
|
||||
# Svix: 8071, ClickHouse: 8123/9009, MinIO: 9090, QStash: 8080
|
||||
# Backend: 8102, Dashboard: 8101
|
||||
EXPOSE 5432 6379 2500 9001 1100 8071 8123 9009 9090 8080 8101 8102
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
26
docker/local-emulator/clickhouse-config.xml
Normal file
26
docker/local-emulator/clickhouse-config.xml
Normal file
@ -0,0 +1,26 @@
|
||||
<clickhouse>
|
||||
<logger>
|
||||
<level>warning</level>
|
||||
<console>1</console>
|
||||
</logger>
|
||||
|
||||
<http_port>8123</http_port>
|
||||
<tcp_port>9009</tcp_port>
|
||||
<listen_host>0.0.0.0</listen_host>
|
||||
|
||||
<path>/data/clickhouse/</path>
|
||||
<tmp_path>/data/clickhouse/tmp/</tmp_path>
|
||||
<user_files_path>/data/clickhouse/user_files/</user_files_path>
|
||||
<format_schema_path>/data/clickhouse/format_schemas/</format_schema_path>
|
||||
|
||||
<max_server_memory_usage_to_ram_ratio>0.5</max_server_memory_usage_to_ram_ratio>
|
||||
|
||||
<user_directories>
|
||||
<users_xml>
|
||||
<path>users.xml</path>
|
||||
</users_xml>
|
||||
<local_directory>
|
||||
<path>/data/clickhouse/access/</path>
|
||||
</local_directory>
|
||||
</user_directories>
|
||||
</clickhouse>
|
||||
35
docker/local-emulator/clickhouse-users.xml
Normal file
35
docker/local-emulator/clickhouse-users.xml
Normal file
@ -0,0 +1,35 @@
|
||||
<clickhouse>
|
||||
<users>
|
||||
<default>
|
||||
<password></password>
|
||||
<networks><ip>::/0</ip></networks>
|
||||
<profile>default</profile>
|
||||
<quota>default</quota>
|
||||
<access_management>1</access_management>
|
||||
</default>
|
||||
<stackframe>
|
||||
<password>PASSWORD-PLACEHOLDER--9gKyMxJeMx</password>
|
||||
<networks><ip>::/0</ip></networks>
|
||||
<profile>default</profile>
|
||||
<quota>default</quota>
|
||||
<access_management>1</access_management>
|
||||
</stackframe>
|
||||
</users>
|
||||
<profiles>
|
||||
<default>
|
||||
<max_memory_usage>1000000000</max_memory_usage>
|
||||
</default>
|
||||
</profiles>
|
||||
<quotas>
|
||||
<default>
|
||||
<interval>
|
||||
<duration>3600</duration>
|
||||
<queries>0</queries>
|
||||
<errors>0</errors>
|
||||
<result_rows>0</result_rows>
|
||||
<read_rows>0</read_rows>
|
||||
<execution_time>0</execution_time>
|
||||
</interval>
|
||||
</default>
|
||||
</quotas>
|
||||
</clickhouse>
|
||||
31
docker/local-emulator/entrypoint.sh
Normal file
31
docker/local-emulator/entrypoint.sh
Normal file
@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
PGDATA=/data/postgres
|
||||
PG_BIN=/usr/lib/postgresql/16/bin
|
||||
|
||||
if [ -z "$(ls -A "$PGDATA" 2>/dev/null)" ]; then
|
||||
gosu postgres "$PG_BIN/initdb" -D "$PGDATA" --no-sync --auth-local=trust --auth-host=md5
|
||||
|
||||
{
|
||||
echo "host all all 0.0.0.0/0 md5"
|
||||
echo "host all all ::/0 md5"
|
||||
} >> "$PGDATA/pg_hba.conf"
|
||||
|
||||
echo "shared_preload_libraries = 'pg_stat_statements'" >> "$PGDATA/postgresql.conf"
|
||||
echo "pg_stat_statements.track = all" >> "$PGDATA/postgresql.conf"
|
||||
|
||||
gosu postgres "$PG_BIN/pg_ctl" -D "$PGDATA" start -w \
|
||||
-o "-c listen_addresses=127.0.0.1 -c shared_preload_libraries=pg_stat_statements"
|
||||
|
||||
gosu postgres psql -c "ALTER USER postgres PASSWORD 'PASSWORD-PLACEHOLDER--uqfEC1hmmv';"
|
||||
gosu postgres psql -c "CREATE DATABASE stackframe;"
|
||||
gosu postgres psql -c "CREATE DATABASE svix;"
|
||||
gosu postgres psql -d stackframe -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;"
|
||||
gosu postgres psql -d stackframe -c "CREATE ROLE anon NOLOGIN;"
|
||||
gosu postgres psql -d stackframe -c "CREATE ROLE authenticated NOLOGIN;"
|
||||
|
||||
gosu postgres "$PG_BIN/pg_ctl" -D "$PGDATA" stop -w
|
||||
fi
|
||||
|
||||
exec /usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf
|
||||
203
docker/local-emulator/generate-env-development.mjs
Normal file
203
docker/local-emulator/generate-env-development.mjs
Normal file
@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(scriptDir, "..", "..");
|
||||
|
||||
const outputPath = path.join(scriptDir, ".env.development");
|
||||
const backendEnvPath = path.join(rootDir, "apps", "backend", ".env.development");
|
||||
const dashboardEnvPath = path.join(rootDir, "apps", "dashboard", ".env.development");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length > 1 || (args[0] != null && args[0] !== "--check")) {
|
||||
throw new Error("Usage: node docker/local-emulator/generate-env-development.mjs [--check]");
|
||||
}
|
||||
|
||||
const parseEnvFile = (filePath) => {
|
||||
const env = new Map();
|
||||
|
||||
for (const rawLine of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
|
||||
const trimmedLine = rawLine.trim();
|
||||
if (trimmedLine === "" || trimmedLine.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separatorIndex = rawLine.indexOf("=");
|
||||
if (separatorIndex < 0) {
|
||||
throw new Error(`Invalid env line in ${filePath}: ${rawLine}`);
|
||||
}
|
||||
|
||||
const key = rawLine.slice(0, separatorIndex).trim();
|
||||
const value = rawLine.slice(separatorIndex + 1);
|
||||
env.set(key, value);
|
||||
}
|
||||
|
||||
return env;
|
||||
};
|
||||
|
||||
const backendEnv = parseEnvFile(backendEnvPath);
|
||||
const dashboardEnv = parseEnvFile(dashboardEnvPath);
|
||||
|
||||
const getRequiredEnvValue = (sourceName, envMap, key) => {
|
||||
const value = envMap.get(key);
|
||||
if (value == null) {
|
||||
throw new Error(`Missing ${key} in ${sourceName}; update the generator or source env file.`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const fromSource = (sourceName, envMap, key) => ({
|
||||
type: "entry",
|
||||
key,
|
||||
value: getRequiredEnvValue(sourceName, envMap, key),
|
||||
});
|
||||
|
||||
const literal = (key, value) => ({
|
||||
type: "entry",
|
||||
key,
|
||||
value,
|
||||
});
|
||||
|
||||
const comment = (value) => ({
|
||||
type: "comment",
|
||||
value,
|
||||
});
|
||||
|
||||
const blank = () => ({
|
||||
type: "blank",
|
||||
});
|
||||
|
||||
const entries = [
|
||||
comment("# Generated by docker/local-emulator/generate-env-development.mjs"),
|
||||
comment("# Do not edit manually; update apps/backend/.env.development, apps/dashboard/.env.development, or this generator."),
|
||||
blank(),
|
||||
comment("# Public emulator/app credentials"),
|
||||
literal("NEXT_PUBLIC_STACK_DOCS_BASE_URL", "https://docs.stack-auth.com"),
|
||||
literal("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", "true"),
|
||||
fromSource("apps/dashboard/.env.development", dashboardEnv, "NEXT_PUBLIC_STACK_PROJECT_ID"),
|
||||
fromSource("apps/dashboard/.env.development", dashboardEnv, "NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY"),
|
||||
fromSource("apps/dashboard/.env.development", dashboardEnv, "STACK_SECRET_SERVER_KEY"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_SERVER_SECRET"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_CHANGELOG_URL"),
|
||||
blank(),
|
||||
comment("# Seed/project defaults"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_ENABLE_DUMMY_PROJECT"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"),
|
||||
blank(),
|
||||
comment("# Third-party/test integrations"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_SVIX_API_KEY"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_OPENAI_API_KEY"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_OPENROUTER_API_KEY"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_STRIPE_SECRET_KEY"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_STRIPE_WEBHOOK_SECRET"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_RESEND_API_KEY"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_RESEND_WEBHOOK_SECRET"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_DNSIMPLE_API_TOKEN"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_DNSIMPLE_ACCOUNT_ID"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_DNSIMPLE_API_BASE_URL"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_FREESTYLE_API_KEY"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_VERCEL_SANDBOX_TOKEN"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "CRON_SECRET"),
|
||||
blank(),
|
||||
comment("# Storage, queueing, and analytics"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_S3_REGION"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_S3_ACCESS_KEY_ID"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_S3_SECRET_ACCESS_KEY"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_S3_BUCKET"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_S3_PRIVATE_BUCKET"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_AWS_REGION"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_AWS_ACCESS_KEY_ID"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_AWS_SECRET_ACCESS_KEY"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_QSTASH_TOKEN"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_QSTASH_CURRENT_SIGNING_KEY"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_QSTASH_NEXT_SIGNING_KEY"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_CLICKHOUSE_ADMIN_USER"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_CLICKHOUSE_ADMIN_PASSWORD"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_CLICKHOUSE_EXTERNAL_PASSWORD"),
|
||||
blank(),
|
||||
comment("# Email and dashboard integration"),
|
||||
literal("STACK_EMAIL_PORT", "2500"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_SECURE"),
|
||||
literal("STACK_EMAIL_USERNAME", "does-not-matter"),
|
||||
literal("STACK_EMAIL_PASSWORD", "does-not-matter"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_SENDER"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_DEFAULT_EMAIL_CAPACITY_PER_HOUR"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_MONITOR_PROJECT_ID"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_MONITOR_RESEND_EMAIL_DOMAIN"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_MONITOR_RESEND_EMAIL_API_KEY"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_MONITOR_SECRET_TOKEN"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_MONITOR_USE_INBUCKET"),
|
||||
fromSource("apps/dashboard/.env.development", dashboardEnv, "STACK_FEATUREBASE_JWT_SECRET"),
|
||||
blank(),
|
||||
comment("# Mock OAuth defaults"),
|
||||
literal("STACK_FORWARD_MOCK_OAUTH_SERVER", "false"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_GITHUB_CLIENT_ID"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_GITHUB_CLIENT_SECRET"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_GOOGLE_CLIENT_ID"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_GOOGLE_CLIENT_SECRET"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_MICROSOFT_CLIENT_ID"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_MICROSOFT_CLIENT_SECRET"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_SPOTIFY_CLIENT_ID"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_SPOTIFY_CLIENT_SECRET"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS"),
|
||||
blank(),
|
||||
comment("# Internal service endpoints (defaults for docker-compose; overridden in QEMU)"),
|
||||
literal("STACK_DATABASE_CONNECTION_STRING", "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@127.0.0.1:5432/stackframe"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_HOST"),
|
||||
literal("STACK_SVIX_SERVER_URL", "http://127.0.0.1:8071"),
|
||||
literal("STACK_S3_ENDPOINT", "http://127.0.0.1:9090"),
|
||||
literal("STACK_QSTASH_URL", "http://127.0.0.1:8080"),
|
||||
literal("STACK_CLICKHOUSE_URL", "http://127.0.0.1:8123"),
|
||||
literal("STACK_CLICKHOUSE_DATABASE", "analytics"),
|
||||
literal("STACK_EMAIL_MONITOR_INBUCKET_API_URL", "http://127.0.0.1:9001"),
|
||||
literal("BACKEND_PORT", "8102"),
|
||||
literal("DASHBOARD_PORT", "8101"),
|
||||
];
|
||||
|
||||
const seenKeys = new Set();
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== "entry") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenKeys.has(entry.key)) {
|
||||
throw new Error(`Duplicate env key in generator: ${entry.key}`);
|
||||
}
|
||||
|
||||
seenKeys.add(entry.key);
|
||||
}
|
||||
|
||||
const content = `${entries.map((entry) => {
|
||||
if (entry.type === "blank") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (entry.type === "comment") {
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
return `${entry.key}=${entry.value}`;
|
||||
}).join("\n")}\n`;
|
||||
|
||||
if (args[0] === "--check") {
|
||||
const currentContent = fs.readFileSync(outputPath, "utf8");
|
||||
if (currentContent !== content) {
|
||||
throw new Error(`${path.relative(rootDir, outputPath)} is out of date. Run pnpm run emulator:generate-env.`);
|
||||
}
|
||||
|
||||
console.log(`${path.relative(rootDir, outputPath)} is up to date.`);
|
||||
} else {
|
||||
fs.writeFileSync(outputPath, content);
|
||||
console.log(`Wrote ${path.relative(rootDir, outputPath)}.`);
|
||||
}
|
||||
30
docker/local-emulator/init-services.sh
Normal file
30
docker/local-emulator/init-services.sh
Normal file
@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
INIT_SERVICES_DONE_FILE=/var/run/stack-local-init-services.done
|
||||
INIT_SERVICES_FAILED_FILE=/var/run/stack-local-init-services.failed
|
||||
|
||||
rm -f "$INIT_SERVICES_DONE_FILE" "$INIT_SERVICES_FAILED_FILE"
|
||||
trap 'touch "$INIT_SERVICES_FAILED_FILE"' ERR
|
||||
|
||||
wait_for_http() {
|
||||
local url="$1" attempts=0
|
||||
while [ "$attempts" -lt 60 ]; do
|
||||
if curl -sf "$url" > /dev/null 2>&1; then return 0; fi
|
||||
sleep 1
|
||||
attempts=$((attempts + 1))
|
||||
done
|
||||
echo "Timed out waiting for $url" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
wait_for_http http://127.0.0.1:9090/minio/health/live
|
||||
mc alias set local http://127.0.0.1:9090 s3mockroot s3mockroot --api S3v4
|
||||
mc mb --ignore-existing local/stack-storage
|
||||
mc mb --ignore-existing local/stack-storage-private
|
||||
|
||||
wait_for_http http://127.0.0.1:8123/ping
|
||||
curl -s "http://127.0.0.1:8123/?user=default" --data "CREATE DATABASE IF NOT EXISTS analytics"
|
||||
|
||||
rm -f "$INIT_SERVICES_FAILED_FILE"
|
||||
touch "$INIT_SERVICES_DONE_FILE"
|
||||
2
docker/local-emulator/qemu/.gitignore
vendored
Normal file
2
docker/local-emulator/qemu/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
.DS_Store
|
||||
run/
|
||||
121
docker/local-emulator/qemu/README.md
Normal file
121
docker/local-emulator/qemu/README.md
Normal file
@ -0,0 +1,121 @@
|
||||
# QEMU Local Emulator
|
||||
|
||||
The local emulator packages the entire Stack Auth backend (PostgreSQL, Redis, ClickHouse, MinIO, Inbucket, Svix, QStash, Dashboard, and Backend) into a single QEMU virtual machine image. Users run it via the `stack emulator` CLI commands.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Host machine
|
||||
└─ QEMU VM (Debian 13 cloud image)
|
||||
└─ Docker container (all-in-one image from ../Dockerfile)
|
||||
├─ PostgreSQL 16
|
||||
├─ Redis 7
|
||||
├─ ClickHouse
|
||||
├─ MinIO
|
||||
├─ Inbucket
|
||||
├─ Svix
|
||||
├─ QStash
|
||||
├─ Stack Dashboard (→ host:26700)
|
||||
└─ Stack Backend (→ host:26701)
|
||||
```
|
||||
|
||||
Only four services are exposed to the host via port forwarding:
|
||||
|
||||
| Service | Host Port | Description |
|
||||
|-----------|-----------|--------------------------|
|
||||
| Dashboard | 26700 | Stack Auth dashboard UI |
|
||||
| Backend | 26701 | Stack Auth API server |
|
||||
| MinIO | 26702 | S3-compatible storage |
|
||||
| Inbucket | 26703 | Email testing interface |
|
||||
|
||||
All other services (PostgreSQL, Redis, ClickHouse, Svix, QStash) remain internal to the VM.
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|--------------------|----------------------------------------------------------------|
|
||||
| `build-image.sh` | Builds a QEMU disk image for a target architecture |
|
||||
| `run-emulator.sh` | Manages the VM lifecycle: `start`, `stop`, `reset`, `status`, `bench` |
|
||||
| `common.sh` | Shared helpers: host detection, QEMU binary selection, firmware lookup, ISO creation |
|
||||
|
||||
## Building an Image
|
||||
|
||||
```bash
|
||||
# Build for current architecture
|
||||
./docker/local-emulator/qemu/build-image.sh
|
||||
|
||||
# Build for a specific architecture (arm64 or amd64)
|
||||
./docker/local-emulator/qemu/build-image.sh arm64
|
||||
|
||||
# Build both
|
||||
./docker/local-emulator/qemu/build-image.sh both
|
||||
```
|
||||
|
||||
The build process:
|
||||
1. Builds the all-in-one Docker image from `../Dockerfile` and exports it as a tarball
|
||||
2. Downloads a Debian 13 cloud base image
|
||||
3. Boots a QEMU VM with cloud-init provisioning (`cloud-init/emulator/user-data`)
|
||||
4. Cloud-init loads the Docker image and runs a full startup cycle to warm caches
|
||||
5. Shuts down and compresses the disk image to `images/stack-emulator-<arch>.qcow2`
|
||||
|
||||
Default resources: 4 CPUs, 4096 MB RAM. Override with `EMULATOR_CPUS` / `EMULATOR_RAM_MB`.
|
||||
|
||||
### Why a single Docker image?
|
||||
|
||||
The `../Dockerfile` bundles all services into one image rather than using separate containers. This keeps the QEMU disk image size small — separate images would each carry their own base layers, significantly inflating the final qcow2.
|
||||
|
||||
## Running the Emulator
|
||||
|
||||
```bash
|
||||
# Via CLI (recommended)
|
||||
stack emulator start
|
||||
stack emulator stop
|
||||
stack emulator reset # wipe data
|
||||
stack emulator status
|
||||
|
||||
# Via script directly
|
||||
EMULATOR_ARCH=arm64 ./docker/local-emulator/qemu/run-emulator.sh start
|
||||
```
|
||||
|
||||
The VM uses an overlay disk (`run/vm/disk.qcow2`) on top of the base image, so data persists across stop/start cycles. Use `reset` to wipe the overlay and start fresh.
|
||||
|
||||
### Hardware acceleration
|
||||
|
||||
- **macOS**: Uses HVF (Hypervisor.framework) for native-arch VMs
|
||||
- **Linux**: Uses KVM when available
|
||||
- **Cross-arch**: Falls back to TCG (software emulation) — significantly slower
|
||||
|
||||
## Optimizations Taken
|
||||
|
||||
- **Single bundled Docker image** to minimize qcow2 size
|
||||
- **Cloud-init provisioning** pre-warms all services during build so first boot is fast
|
||||
- **Overlay disks** avoid copying the multi-GB base image on each start
|
||||
- **Compressed qcow2** images (`-c` flag) reduce download size
|
||||
- **Only 4 ports forwarded** to minimize host-side surface area
|
||||
|
||||
## Possible Future Optimizations
|
||||
|
||||
- External server for reads and writes to relative dir instead of full host access allowing snapshots
|
||||
- Or copying the config file on start with --config-file <path> enforced and writing the config file to host directory on stop
|
||||
|
||||
## Updating the Image
|
||||
|
||||
1. Make changes to the `../Dockerfile`, `../entrypoint.sh`, or cloud-init config
|
||||
2. Rebuild: `./docker/local-emulator/qemu/build-image.sh <arch>`
|
||||
3. The CI workflow (`.github/workflows/qemu-emulator-build.yaml`) builds and publishes images on push to `main`/`dev`
|
||||
4. Users pull the latest via `stack emulator pull`
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
qemu/
|
||||
├── build-image.sh # Image builder
|
||||
├── run-emulator.sh # VM lifecycle manager
|
||||
├── common.sh # Shared utilities
|
||||
├── cloud-init/
|
||||
│ └── emulator/
|
||||
│ ├── meta-data # VM instance metadata
|
||||
│ └── user-data # Provisioning script
|
||||
├── images/ # Built qcow2 images (gitignored)
|
||||
└── run/ # Runtime state: overlay disk, PID, logs (gitignored)
|
||||
```
|
||||
290
docker/local-emulator/qemu/build-image.sh
Executable file
290
docker/local-emulator/qemu/build-image.sh
Executable file
@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
IMAGE_DIR="$SCRIPT_DIR/images"
|
||||
CLOUD_INIT_ROOT="$SCRIPT_DIR/cloud-init"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
DEBIAN_VERSION="${DEBIAN_VERSION:-13}"
|
||||
DISK_SIZE="${EMULATOR_DISK_SIZE:-12G}"
|
||||
RAM="${EMULATOR_BUILD_RAM:-4096}"
|
||||
CPUS="${EMULATOR_BUILD_CPUS:-$(sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 4)}"
|
||||
PROVISION_TIMEOUT="${EMULATOR_PROVISION_TIMEOUT:-3200}"
|
||||
EMULATOR_IMAGE_NAME="${EMULATOR_IMAGE_NAME:-stack-local-emulator}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[build]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[build]${NC} $*"; }
|
||||
err() { echo -e "${RED}[build]${NC} $*" >&2; }
|
||||
|
||||
detect_host
|
||||
TARGET_ARCH="${1:-$HOST_ARCH}"
|
||||
|
||||
TARGET_ARCHS=()
|
||||
case "$TARGET_ARCH" in
|
||||
arm64) TARGET_ARCHS=(arm64) ;;
|
||||
amd64) TARGET_ARCHS=(amd64) ;;
|
||||
both) TARGET_ARCHS=(arm64 amd64) ;;
|
||||
*) err "Usage: $0 [arm64|amd64|both]"; exit 1 ;;
|
||||
esac
|
||||
|
||||
DOCKER_IMAGES=("$EMULATOR_IMAGE_NAME")
|
||||
|
||||
check_deps() {
|
||||
local missing=()
|
||||
local arch qemu_bin
|
||||
|
||||
for arch in "${TARGET_ARCHS[@]}"; do
|
||||
qemu_bin="$(qemu_binary_for_arch "$arch")"
|
||||
command -v "$qemu_bin" >/dev/null 2>&1 || missing+=("$qemu_bin")
|
||||
done
|
||||
|
||||
for cmd in qemu-img curl docker gzip; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
|
||||
done
|
||||
|
||||
if ! command -v mkisofs >/dev/null 2>&1 && ! command -v genisoimage >/dev/null 2>&1 && ! command -v hdiutil >/dev/null 2>&1; then
|
||||
missing+=("mkisofs/genisoimage/hdiutil")
|
||||
fi
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
err "Missing build dependencies: ${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_deps
|
||||
mkdir -p "$IMAGE_DIR"
|
||||
|
||||
download_cloud_image() {
|
||||
local arch="$1"
|
||||
local dest="$2"
|
||||
local deb_arch
|
||||
|
||||
case "$arch" in
|
||||
arm64) deb_arch="arm64" ;;
|
||||
amd64) deb_arch="amd64" ;;
|
||||
*) err "Unsupported target arch: $arch"; exit 1 ;;
|
||||
esac
|
||||
|
||||
local url="https://cloud.debian.org/images/cloud/trixie/daily/latest/debian-${DEBIAN_VERSION}-generic-${deb_arch}-daily.qcow2"
|
||||
if [ -f "$dest" ]; then
|
||||
log "Base image already cached: $dest"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Downloading Debian ${DEBIAN_VERSION} cloud image for ${arch}..."
|
||||
curl -fSL --progress-bar -o "$dest" "$url"
|
||||
}
|
||||
|
||||
docker_platform_for_arch() {
|
||||
case "$1" in
|
||||
arm64) echo "linux/arm64" ;;
|
||||
amd64) echo "linux/amd64" ;;
|
||||
*) err "Unsupported target arch: $1"; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_local_emulator_image() {
|
||||
local arch="$1"
|
||||
local platform
|
||||
platform="$(docker_platform_for_arch "$arch")"
|
||||
|
||||
log "Building Docker emulator image (${arch})..."
|
||||
docker buildx build \
|
||||
--platform "$platform" \
|
||||
--tag "$EMULATOR_IMAGE_NAME" \
|
||||
--load \
|
||||
-f "$REPO_ROOT/docker/local-emulator/Dockerfile" \
|
||||
"$REPO_ROOT"
|
||||
}
|
||||
|
||||
qemu_cmd_prefix_for_arch() {
|
||||
local arch="$1"
|
||||
case "$arch" in
|
||||
arm64)
|
||||
local accel="tcg"
|
||||
if [ "$HOST_ARCH" = "arm64" ]; then
|
||||
case "$HOST_OS" in
|
||||
darwin) accel="hvf" ;;
|
||||
linux) [ -w /dev/kvm ] && accel="kvm" ;;
|
||||
esac
|
||||
fi
|
||||
local firmware
|
||||
firmware="$(find_aarch64_firmware)"
|
||||
echo "qemu-system-aarch64 -machine virt -accel $accel -cpu max -bios $firmware"
|
||||
;;
|
||||
amd64)
|
||||
local accel="tcg"
|
||||
local cpu="max"
|
||||
if [ "$HOST_ARCH" = "amd64" ]; then
|
||||
case "$HOST_OS" in
|
||||
darwin) accel="hvf" ;;
|
||||
linux) [ -w /dev/kvm ] && accel="kvm" ;;
|
||||
esac
|
||||
else
|
||||
cpu="qemu64"
|
||||
fi
|
||||
echo "qemu-system-x86_64 -machine q35 -accel $accel -cpu $cpu"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
final_image_name() {
|
||||
echo "$IMAGE_DIR/stack-emulator-$1.qcow2"
|
||||
}
|
||||
|
||||
prepare_bundle_artifacts() {
|
||||
local arch="$1"
|
||||
local bundle_tgz="$IMAGE_DIR/emulator-${arch}-docker-images.tar.gz"
|
||||
local bundle_meta="$bundle_tgz.image-ids"
|
||||
|
||||
local current_ids=""
|
||||
for img in "${DOCKER_IMAGES[@]}"; do
|
||||
current_ids+="$(docker image inspect --format '{{.ID}}' "$img")"$'\n'
|
||||
done
|
||||
|
||||
local cached_ids=""
|
||||
if [ -f "$bundle_meta" ]; then
|
||||
cached_ids="$(cat "$bundle_meta")"
|
||||
fi
|
||||
|
||||
if [ -f "$bundle_tgz" ] && [ "$cached_ids" = "$current_ids" ]; then
|
||||
log "Reusing bundle: $bundle_tgz"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Creating Docker image bundle (${arch})..."
|
||||
for img in "${DOCKER_IMAGES[@]}"; do
|
||||
if ! docker image inspect "$img" >/dev/null 2>&1; then
|
||||
err "Missing Docker image: $img. Build the local emulator images first, then rerun the QEMU image build."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
local tmp_bundle="${bundle_tgz}.tmp"
|
||||
rm -f "$tmp_bundle"
|
||||
docker save "${DOCKER_IMAGES[@]}" | gzip -c > "$tmp_bundle"
|
||||
mv "$tmp_bundle" "$bundle_tgz"
|
||||
printf "%s" "$current_ids" > "$bundle_meta"
|
||||
}
|
||||
|
||||
build_one() {
|
||||
local arch="$1"
|
||||
local base_img="$IMAGE_DIR/debian-${DEBIAN_VERSION}-base-${arch}.qcow2"
|
||||
local bundle_tgz="$IMAGE_DIR/emulator-${arch}-docker-images.tar.gz"
|
||||
local final_img
|
||||
final_img="$(final_image_name "$arch")"
|
||||
|
||||
log "━━━ Building emulator image (${arch}) ━━━"
|
||||
|
||||
local tmp_dir
|
||||
tmp_dir="$(mktemp -d /tmp/stack-qemu-build-${arch}-XXXXXX)"
|
||||
local tmp_img="$tmp_dir/disk.qcow2"
|
||||
local seed_iso="$tmp_dir/seed.iso"
|
||||
local bundle_iso="$tmp_dir/bundle.iso"
|
||||
local bundle_dir="$tmp_dir/bundle"
|
||||
local serial_log="$tmp_dir/serial.log"
|
||||
local pidfile="$tmp_dir/qemu.pid"
|
||||
local qemu_base pid elapsed
|
||||
local start_time=$SECONDS
|
||||
|
||||
cp "$base_img" "$tmp_img"
|
||||
qemu-img resize "$tmp_img" "$DISK_SIZE" >/dev/null 2>&1 || true
|
||||
|
||||
local seed_dir
|
||||
seed_dir="$(mktemp -d)"
|
||||
mkdir -p "$seed_dir"
|
||||
cp "$CLOUD_INIT_ROOT/emulator/meta-data" "$seed_dir/meta-data"
|
||||
cp "$CLOUD_INIT_ROOT/emulator/user-data" "$seed_dir/user-data"
|
||||
make_iso_from_dir "$seed_iso" "cidata" "$seed_dir"
|
||||
rm -rf "$seed_dir"
|
||||
|
||||
mkdir -p "$bundle_dir"
|
||||
cp "$bundle_tgz" "$bundle_dir/img.tgz"
|
||||
make_iso_from_dir "$bundle_iso" "STACKBUNDLE" "$bundle_dir"
|
||||
|
||||
: > "$serial_log"
|
||||
qemu_base="$(qemu_cmd_prefix_for_arch "$arch")"
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
$qemu_base \
|
||||
-boot order=c \
|
||||
-m "$RAM" \
|
||||
-smp "$CPUS" \
|
||||
-drive "file=$tmp_img,format=qcow2,if=virtio" \
|
||||
-drive "file=$seed_iso,format=raw,if=virtio,readonly=on" \
|
||||
-drive "file=$bundle_iso,format=raw,if=virtio,readonly=on" \
|
||||
-netdev user,id=net0 \
|
||||
-device virtio-net-pci,netdev=net0 \
|
||||
-serial "file:$serial_log" \
|
||||
-display none \
|
||||
-daemonize \
|
||||
-pidfile "$pidfile"
|
||||
|
||||
pid="$(cat "$pidfile")"
|
||||
elapsed=0
|
||||
while [ "$elapsed" -lt "$PROVISION_TIMEOUT" ]; do
|
||||
if grep -q "STACK_CLOUD_INIT_DONE" "$serial_log" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
elapsed=$((SECONDS - start_time))
|
||||
printf "\r [%3ds / %ds] provisioning emulator..." "$elapsed" "$PROVISION_TIMEOUT"
|
||||
done
|
||||
echo ""
|
||||
|
||||
if ! grep -q "STACK_CLOUD_INIT_DONE" "$serial_log" 2>/dev/null; then
|
||||
err "Provisioning timed out for emulator (${arch})"
|
||||
tail -50 "$serial_log" >&2 || true
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
sleep 1
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$tmp_dir"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local shutdown_wait=0
|
||||
while [ "$shutdown_wait" -lt 90 ] && kill -0 "$pid" 2>/dev/null; do
|
||||
sleep 1
|
||||
shutdown_wait=$((shutdown_wait + 1))
|
||||
done
|
||||
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
warn "Guest did not power off cleanly; forcing shutdown."
|
||||
kill "$pid" 2>/dev/null || true
|
||||
sleep 2
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
cp "$tmp_img" "$final_img"
|
||||
cp "$serial_log" "$IMAGE_DIR/provision-emulator-${arch}.log"
|
||||
rm -rf "$tmp_dir"
|
||||
|
||||
log "Compressing final image (this may take several minutes)..."
|
||||
qemu-img convert -p -O qcow2 -c "$final_img" "$final_img.tmp"
|
||||
mv "$final_img.tmp" "$final_img"
|
||||
|
||||
local size
|
||||
size="$(du -h "$final_img" | cut -f1)"
|
||||
log "━━━ Emulator image ready: $final_img (${size}) ━━━"
|
||||
}
|
||||
|
||||
for arch in "${TARGET_ARCHS[@]}"; do
|
||||
local_base="$IMAGE_DIR/debian-${DEBIAN_VERSION}-base-${arch}.qcow2"
|
||||
download_cloud_image "$arch" "$local_base"
|
||||
build_local_emulator_image "$arch"
|
||||
prepare_bundle_artifacts "$arch"
|
||||
build_one "$arch"
|
||||
done
|
||||
|
||||
log "Done. Start with: docker/local-emulator/qemu/run-emulator.sh start"
|
||||
2
docker/local-emulator/qemu/cloud-init/emulator/meta-data
Normal file
2
docker/local-emulator/qemu/cloud-init/emulator/meta-data
Normal file
@ -0,0 +1,2 @@
|
||||
instance-id: stack-emulator-001
|
||||
local-hostname: stack-emulator
|
||||
185
docker/local-emulator/qemu/cloud-init/emulator/user-data
Normal file
185
docker/local-emulator/qemu/cloud-init/emulator/user-data
Normal file
@ -0,0 +1,185 @@
|
||||
#cloud-config
|
||||
|
||||
hostname: stack-emulator
|
||||
manage_etc_hosts: true
|
||||
|
||||
users:
|
||||
- name: stack
|
||||
shell: /bin/bash
|
||||
sudo: ALL=(ALL) NOPASSWD:ALL
|
||||
lock_passwd: false
|
||||
|
||||
chpasswd:
|
||||
list: |
|
||||
root:stack-emulator
|
||||
stack:stack-emulator
|
||||
expire: false
|
||||
|
||||
ssh_pwauth: false
|
||||
|
||||
package_update: true
|
||||
package_upgrade: false
|
||||
|
||||
packages:
|
||||
- docker.io
|
||||
- ca-certificates
|
||||
- curl
|
||||
- netcat-openbsd
|
||||
- qemu-guest-agent
|
||||
|
||||
write_files:
|
||||
- path: /usr/local/bin/install-emulator-containers
|
||||
permissions: '0755'
|
||||
content: |
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p /mnt/stack-bundle
|
||||
bundle_device="$(readlink -f /dev/disk/by-label/STACKBUNDLE)"
|
||||
mount -o ro "$bundle_device" /mnt/stack-bundle
|
||||
|
||||
systemctl enable --now docker
|
||||
until docker info >/dev/null 2>&1; do sleep 1; done
|
||||
|
||||
gzip -dc /mnt/stack-bundle/img.tgz | docker load
|
||||
|
||||
- path: /usr/local/bin/render-stack-env
|
||||
permissions: '0755'
|
||||
content: |
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p /mnt/stack-runtime /run/stack-auth
|
||||
runtime_device="$(readlink -f /dev/disk/by-label/STACKCFG)"
|
||||
mountpoint -q /mnt/stack-runtime || mount -o ro "$runtime_device" /mnt/stack-runtime
|
||||
|
||||
set -a
|
||||
source /mnt/stack-runtime/runtime.env
|
||||
source /mnt/stack-runtime/base.env
|
||||
set +a
|
||||
|
||||
# Container-local dependencies run on localhost. Host-only development
|
||||
# services (such as the OAuth mock server) are reachable via the QEMU
|
||||
# user-network host alias.
|
||||
DEPS_HOST=127.0.0.1
|
||||
HOST_SERVICES_HOST=10.0.2.2
|
||||
P="$STACK_EMULATOR_PORT_PREFIX"
|
||||
|
||||
{
|
||||
# Static vars from base config and runtime (e.g. API keys, feature flags)
|
||||
cat /mnt/stack-runtime/base.env
|
||||
cat /mnt/stack-runtime/runtime.env
|
||||
|
||||
# Computed vars — depend on port prefix or deps host
|
||||
cat <<COMPUTED
|
||||
NEXT_PUBLIC_STACK_PORT_PREFIX=${P}
|
||||
STACK_RUNTIME_WORK_DIR=/app
|
||||
STACK_LOCAL_EMULATOR_HOST_MOUNT_ROOT=/host
|
||||
NEXT_PUBLIC_STACK_API_URL=http://localhost:${P}02
|
||||
NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:${P}01
|
||||
NEXT_PUBLIC_BROWSER_STACK_API_URL=http://localhost:${P}02
|
||||
NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL=http://localhost:${P}01
|
||||
NEXT_PUBLIC_SERVER_STACK_API_URL=http://127.0.0.1:${P}02
|
||||
NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL=http://127.0.0.1:${P}01
|
||||
NEXT_PUBLIC_STACK_SVIX_SERVER_URL=http://localhost:${P}13
|
||||
STACK_DATABASE_CONNECTION_STRING=postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@${DEPS_HOST}:5432/stackframe
|
||||
STACK_EMAIL_HOST=${DEPS_HOST}
|
||||
STACK_SVIX_SERVER_URL=http://${DEPS_HOST}:8071
|
||||
STACK_S3_ENDPOINT=http://${DEPS_HOST}:9090
|
||||
STACK_S3_PUBLIC_ENDPOINT=http://localhost:${P}21/stack-storage
|
||||
STACK_QSTASH_URL=http://${DEPS_HOST}:8080
|
||||
STACK_CLICKHOUSE_URL=http://${DEPS_HOST}:8123
|
||||
STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:${P}01/handler/email-verification
|
||||
STACK_EMAIL_MONITOR_INBUCKET_API_URL=http://${DEPS_HOST}:9001
|
||||
STACK_OAUTH_MOCK_URL=http://${HOST_SERVICES_HOST}:${P}14
|
||||
BACKEND_PORT=${P}02
|
||||
DASHBOARD_PORT=${P}01
|
||||
COMPUTED
|
||||
} > /run/stack-auth/local-emulator.env
|
||||
|
||||
- path: /usr/local/bin/mount-host-fs
|
||||
permissions: '0755'
|
||||
content: |
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
mkdir -p /host
|
||||
if ! mountpoint -q /host; then
|
||||
if ! mount -t 9p -o trans=virtio,version=9p2000.L hostfs /host; then
|
||||
echo "Failed to mount host filesystem at /host" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- path: /usr/local/bin/run-stack-container
|
||||
permissions: '0755'
|
||||
content: |
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
/usr/local/bin/mount-host-fs
|
||||
/usr/local/bin/render-stack-env
|
||||
docker rm -f stack >/dev/null 2>&1 || true
|
||||
exec docker run \
|
||||
--rm \
|
||||
--name stack \
|
||||
--network host \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--env-file /run/stack-auth/local-emulator.env \
|
||||
-v stack-postgres-data:/data/postgres \
|
||||
-v stack-redis-data:/data/redis \
|
||||
-v stack-clickhouse-data:/data/clickhouse \
|
||||
-v stack-minio-data:/data/minio \
|
||||
-v stack-inbucket-data:/data/inbucket \
|
||||
-v /host:/host \
|
||||
stack-local-emulator
|
||||
|
||||
- path: /usr/local/bin/wait-for-deps
|
||||
permissions: '0755'
|
||||
content: |
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
until nc -z 127.0.0.1 5432 >/dev/null 2>&1; do sleep 1; done
|
||||
until curl -sf http://127.0.0.1:8123/ping >/dev/null 2>&1; do sleep 1; done
|
||||
until curl -sf http://127.0.0.1:8071/api/v1/health/ >/dev/null 2>&1; do sleep 1; done
|
||||
until curl -sf http://127.0.0.1:9090/minio/health/live >/dev/null 2>&1; do sleep 1; done
|
||||
until [ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/ 2>/dev/null || true)" = "401" ]; do sleep 1; done
|
||||
|
||||
- path: /etc/systemd/system/stack.service
|
||||
content: |
|
||||
[Unit]
|
||||
Description=Stack Auth local emulator
|
||||
Wants=network-online.target docker.service
|
||||
After=network-online.target docker.service
|
||||
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStartSec=0
|
||||
ExecStart=/usr/local/bin/run-stack-container
|
||||
ExecStop=/usr/bin/docker stop stack
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
runcmd:
|
||||
- systemctl disable --now ssh || true
|
||||
- systemctl mask ssh || true
|
||||
- bash /usr/local/bin/install-emulator-containers
|
||||
- systemctl daemon-reload
|
||||
- systemctl enable stack.service
|
||||
- docker run --rm --name stack-build-init
|
||||
--network host
|
||||
-e STACK_DEPS_ONLY=true
|
||||
-v stack-postgres-data:/data/postgres
|
||||
-v stack-redis-data:/data/redis
|
||||
-v stack-clickhouse-data:/data/clickhouse
|
||||
-v stack-minio-data:/data/minio
|
||||
-v stack-inbucket-data:/data/inbucket
|
||||
-d stack-local-emulator
|
||||
- bash /usr/local/bin/wait-for-deps
|
||||
- docker stop stack-build-init || true
|
||||
- echo "STACK_CLOUD_INIT_DONE" > /dev/console 2>/dev/null || true
|
||||
- echo "STACK_CLOUD_INIT_DONE" > /dev/ttyAMA0 2>/dev/null || true
|
||||
- echo "STACK_CLOUD_INIT_DONE" > /dev/ttyS0 2>/dev/null || true
|
||||
- shutdown -P now
|
||||
70
docker/local-emulator/qemu/common.sh
Executable file
70
docker/local-emulator/qemu/common.sh
Executable file
@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shared helpers for QEMU emulator scripts.
|
||||
# Source this file; do not execute it directly.
|
||||
|
||||
AARCH64_FIRMWARE_PATHS=(
|
||||
/opt/homebrew/share/qemu/edk2-aarch64-code.fd
|
||||
/usr/share/qemu/edk2-aarch64-code.fd
|
||||
/usr/share/AAVMF/AAVMF_CODE.fd
|
||||
/usr/share/qemu-efi-aarch64/QEMU_EFI.fd
|
||||
)
|
||||
|
||||
detect_host() {
|
||||
case "$(uname -m)" in
|
||||
arm64|aarch64) HOST_ARCH="arm64" ;;
|
||||
x86_64|amd64) HOST_ARCH="amd64" ;;
|
||||
*) echo "Unsupported host architecture: $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin) HOST_OS="darwin" ;;
|
||||
Linux) HOST_OS="linux" ;;
|
||||
MINGW*|MSYS*|CYGWIN*) HOST_OS="windows" ;;
|
||||
*) HOST_OS="unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
qemu_binary_for_arch() {
|
||||
case "$1" in
|
||||
arm64) echo "qemu-system-aarch64" ;;
|
||||
amd64) echo "qemu-system-x86_64" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
find_aarch64_firmware() {
|
||||
local p
|
||||
for p in "${AARCH64_FIRMWARE_PATHS[@]}"; do
|
||||
if [ -f "$p" ]; then
|
||||
echo "$p"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo "No aarch64 UEFI firmware found." >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
make_iso_from_dir() {
|
||||
local iso_path="$1"
|
||||
local volume_name="$2"
|
||||
local source_dir="$3"
|
||||
|
||||
rm -f "$iso_path" "${iso_path}.iso"
|
||||
if command -v hdiutil >/dev/null 2>&1; then
|
||||
local tmp_dir
|
||||
tmp_dir="$(mktemp -d /tmp/stack-emulator-iso-XXXXXX)"
|
||||
cp -R "$source_dir/." "$tmp_dir/"
|
||||
hdiutil makehybrid -o "$iso_path" "$tmp_dir" -joliet -iso -default-volume-name "$volume_name" 2>/dev/null
|
||||
if [ -f "${iso_path}.iso" ]; then
|
||||
mv "${iso_path}.iso" "$iso_path"
|
||||
fi
|
||||
rm -rf "$tmp_dir"
|
||||
elif command -v mkisofs >/dev/null 2>&1; then
|
||||
mkisofs -output "$iso_path" -volid "$volume_name" -joliet -rock "$source_dir" >/dev/null 2>&1
|
||||
elif command -v genisoimage >/dev/null 2>&1; then
|
||||
genisoimage -output "$iso_path" -volid "$volume_name" -joliet -rock "$source_dir" >/dev/null 2>&1
|
||||
else
|
||||
echo "Missing ISO creation tool (need hdiutil, mkisofs, or genisoimage)" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
2
docker/local-emulator/qemu/images/.gitignore
vendored
Normal file
2
docker/local-emulator/qemu/images/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
391
docker/local-emulator/qemu/run-emulator.sh
Executable file
391
docker/local-emulator/qemu/run-emulator.sh
Executable file
@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
IMAGE_DIR="$SCRIPT_DIR/images"
|
||||
RUN_DIR="${EMULATOR_RUN_DIR:-$SCRIPT_DIR/run}"
|
||||
|
||||
VM_RAM="${EMULATOR_RAM:-4096}"
|
||||
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'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() { echo -e "${GREEN}[emulator]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[emulator]${NC} $*"; }
|
||||
err() { echo -e "${RED}[emulator]${NC} $*" >&2; }
|
||||
info() { echo -e "${CYAN}[emulator]${NC} $*"; }
|
||||
|
||||
|
||||
detect_host
|
||||
ARCH="${EMULATOR_ARCH:-$HOST_ARCH}"
|
||||
|
||||
select_accelerator() {
|
||||
local accel="tcg"
|
||||
if [ "$ARCH" = "$HOST_ARCH" ]; then
|
||||
case "$HOST_OS" in
|
||||
darwin)
|
||||
if "$(qemu_binary_for_arch "$ARCH")" -accel help 2>&1 | grep -q hvf; then
|
||||
accel="hvf"
|
||||
fi
|
||||
;;
|
||||
linux)
|
||||
if [ -w /dev/kvm ]; then
|
||||
accel="kvm"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
ACCEL="$accel"
|
||||
}
|
||||
|
||||
select_accelerator
|
||||
|
||||
VM_DIR="$RUN_DIR/vm"
|
||||
|
||||
image_path() {
|
||||
echo "$IMAGE_DIR/stack-emulator-$ARCH.qcow2"
|
||||
}
|
||||
|
||||
runtime_iso_path() {
|
||||
echo "$VM_DIR/runtime-config.iso"
|
||||
}
|
||||
|
||||
# Returns a fast fingerprint (size:mtime) of the base QEMU image.
|
||||
# Used to detect whether the image has changed since the overlay was created.
|
||||
base_image_fingerprint() {
|
||||
local img="$1"
|
||||
case "$HOST_OS" in
|
||||
darwin) stat -f "%z:%m" "$img" 2>/dev/null ;;
|
||||
linux) stat -c "%s:%Y" "$img" 2>/dev/null ;;
|
||||
*) stat -f "%z:%m" "$img" 2>/dev/null || stat -c "%s:%Y" "$img" 2>/dev/null ;;
|
||||
esac
|
||||
}
|
||||
|
||||
prepare_runtime_config_iso() {
|
||||
local cfg_dir="$VM_DIR/runtime-config"
|
||||
local cfg_iso
|
||||
cfg_iso="$(runtime_iso_path)"
|
||||
rm -rf "$cfg_dir"
|
||||
mkdir -p "$cfg_dir"
|
||||
{
|
||||
printf "STACK_EMULATOR_PORT_PREFIX=%s\n" "$PORT_PREFIX"
|
||||
} > "$cfg_dir/runtime.env"
|
||||
cp "$SCRIPT_DIR/../.env.development" "$cfg_dir/base.env"
|
||||
make_iso_from_dir "$cfg_iso" "STACKCFG" "$cfg_dir"
|
||||
}
|
||||
|
||||
service_is_up() {
|
||||
local port="$1"
|
||||
local proto="$2"
|
||||
local path="${3:-/}"
|
||||
local expected_codes="${4:-200}"
|
||||
|
||||
if [ "$proto" = "tcp" ]; then
|
||||
nc -z -w2 127.0.0.1 "$port" 2>/dev/null
|
||||
return $?
|
||||
fi
|
||||
|
||||
local code
|
||||
code="$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 "http://127.0.0.1:${port}${path}" 2>/dev/null || true)"
|
||||
local expected
|
||||
for expected in ${expected_codes//,/ }; do
|
||||
if [ "$code" = "$expected" ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
deps_ready() {
|
||||
service_is_up "$EMULATOR_MINIO_PORT" http /minio/health/live &&
|
||||
service_is_up "$EMULATOR_INBUCKET_PORT" http /
|
||||
}
|
||||
|
||||
app_ready() {
|
||||
service_is_up "$EMULATOR_BACKEND_PORT" http "/health?db=1" &&
|
||||
service_is_up "$EMULATOR_DASHBOARD_PORT" http /handler/sign-in
|
||||
}
|
||||
|
||||
all_ready() {
|
||||
deps_ready && app_ready
|
||||
}
|
||||
|
||||
wait_for_condition() {
|
||||
local label="$1"
|
||||
local timeout="$2"
|
||||
local check_fn="$3"
|
||||
local started=$SECONDS
|
||||
local elapsed=0
|
||||
|
||||
log "Waiting for ${label}..."
|
||||
while [ "$elapsed" -lt "$timeout" ]; do
|
||||
if "$check_fn"; then
|
||||
echo ""
|
||||
log "${label} ready in ${elapsed}s"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((SECONDS - started))
|
||||
printf "\r [%3ds] %s..." "$elapsed" "$label"
|
||||
done
|
||||
echo ""
|
||||
return 1
|
||||
}
|
||||
|
||||
build_qemu_cmd() {
|
||||
local base_img
|
||||
base_img="$(image_path)"
|
||||
|
||||
if [ ! -f "$base_img" ]; then
|
||||
err "Missing QEMU image: $base_img"
|
||||
err "Run docker/local-emulator/qemu/build-image.sh $ARCH first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$VM_DIR"
|
||||
local fingerprint_file="$VM_DIR/base-image.fingerprint"
|
||||
local current_fp
|
||||
current_fp="$(base_image_fingerprint "$base_img")"
|
||||
if [ -f "$VM_DIR/disk.qcow2" ]; then
|
||||
if [ -f "$fingerprint_file" ] && [ "$(cat "$fingerprint_file")" = "$current_fp" ]; then
|
||||
log "Reusing existing overlay disk (changes persist)"
|
||||
else
|
||||
warn "QEMU base image has changed — recreating overlay."
|
||||
rm -f "$VM_DIR/disk.qcow2" "$fingerprint_file"
|
||||
fi
|
||||
fi
|
||||
if [ ! -f "$VM_DIR/disk.qcow2" ]; then
|
||||
qemu-img create -f qcow2 -b "$base_img" -F qcow2 "$VM_DIR/disk.qcow2" >/dev/null
|
||||
base_image_fingerprint "$base_img" > "$fingerprint_file"
|
||||
fi
|
||||
|
||||
local qemu_bin machine cpu firmware_args=()
|
||||
qemu_bin="$(qemu_binary_for_arch "$ARCH")"
|
||||
case "$ARCH" in
|
||||
arm64)
|
||||
machine="virt"
|
||||
cpu="max"
|
||||
local firmware
|
||||
firmware="$(find_aarch64_firmware)"
|
||||
firmware_args=(-bios "$firmware")
|
||||
;;
|
||||
amd64)
|
||||
machine="q35"
|
||||
if [ "$ACCEL" = "tcg" ] && [ "$HOST_ARCH" != "amd64" ]; then
|
||||
cpu="qemu64"
|
||||
else
|
||||
cpu="max"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
local netdev="user,id=net0"
|
||||
# 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"
|
||||
-machine "$machine"
|
||||
-accel "$ACCEL"
|
||||
-cpu "$cpu"
|
||||
"${firmware_args[@]}"
|
||||
-boot order=c
|
||||
-m "$VM_RAM"
|
||||
-smp "$VM_CPUS"
|
||||
-drive "file=$VM_DIR/disk.qcow2,format=qcow2,if=virtio"
|
||||
-drive "file=$(runtime_iso_path),format=raw,if=virtio,readonly=on"
|
||||
-netdev "$netdev"
|
||||
-device virtio-net-pci,netdev=net0
|
||||
-device virtio-balloon-pci
|
||||
-virtfs "local,path=/,mount_tag=hostfs,security_model=none"
|
||||
-chardev "socket,id=monitor,path=$VM_DIR/monitor.sock,server=on,wait=off"
|
||||
-mon "chardev=monitor,mode=control"
|
||||
-serial "file:$VM_DIR/serial.log"
|
||||
-display none
|
||||
-daemonize
|
||||
-pidfile "$VM_DIR/qemu.pid"
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
is_running() {
|
||||
if [ ! -f "$VM_DIR/qemu.pid" ]; then
|
||||
return 1
|
||||
fi
|
||||
local pid
|
||||
pid="$(cat "$VM_DIR/qemu.pid")"
|
||||
kill -0 "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
tail_vm_logs() {
|
||||
if [ -f "$VM_DIR/serial.log" ]; then
|
||||
echo ""
|
||||
warn "Last serial log lines:"
|
||||
tail -40 "$VM_DIR/serial.log" || true
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_ports_free() {
|
||||
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 any conflicting services first."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
start_vm() {
|
||||
mkdir -p "$VM_DIR"
|
||||
: > "$VM_DIR/serial.log"
|
||||
prepare_runtime_config_iso
|
||||
build_qemu_cmd
|
||||
"${QEMU_CMD[@]}"
|
||||
}
|
||||
|
||||
stop_vm() {
|
||||
if [ ! -f "$VM_DIR/qemu.pid" ]; then
|
||||
return 0
|
||||
fi
|
||||
local pid
|
||||
pid="$(cat "$VM_DIR/qemu.pid")"
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
if [ -S "$VM_DIR/monitor.sock" ]; then
|
||||
echo '{"execute":"qmp_capabilities"}' | socat - UNIX-CONNECT:"$VM_DIR/monitor.sock" >/dev/null 2>&1 || true
|
||||
echo '{"execute":"system_powerdown"}' | socat - UNIX-CONNECT:"$VM_DIR/monitor.sock" >/dev/null 2>&1 || true
|
||||
sleep 3
|
||||
fi
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
sleep 1
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
rm -f "$VM_DIR/qemu.pid" "$VM_DIR/monitor.sock" "$VM_DIR/serial.log"
|
||||
rm -rf "$VM_DIR/runtime-config"
|
||||
rm -f "$VM_DIR/runtime-config.iso"
|
||||
}
|
||||
|
||||
cmd_start() {
|
||||
ensure_ports_free
|
||||
mkdir -p "$RUN_DIR"
|
||||
|
||||
info "Starting QEMU local emulator"
|
||||
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
|
||||
|
||||
info "VM: ${VM_RAM}MB / ${VM_CPUS} CPUs"
|
||||
|
||||
if ! wait_for_condition "deps services" "$READY_TIMEOUT" deps_ready; then
|
||||
tail_vm_logs
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! wait_for_condition "dashboard/backend" "$READY_TIMEOUT" app_ready; then
|
||||
tail_vm_logs
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "All services are green."
|
||||
info "Dashboard: http://localhost:${EMULATOR_DASHBOARD_PORT}"
|
||||
info "Backend: http://localhost:${EMULATOR_BACKEND_PORT}"
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
stop_vm
|
||||
log "QEMU emulator stopped."
|
||||
}
|
||||
|
||||
cmd_reset() {
|
||||
cmd_stop 2>/dev/null || true
|
||||
rm -rf "$RUN_DIR"
|
||||
log "Emulator state reset. Next start will be a fresh boot."
|
||||
}
|
||||
|
||||
STATUS_FAILED=0
|
||||
|
||||
print_service_status() {
|
||||
local name="$1"
|
||||
local port="$2"
|
||||
local proto="$3"
|
||||
local path="${4:-/}"
|
||||
local expected_codes="${5:-200}"
|
||||
if service_is_up "$port" "$proto" "$path" "$expected_codes"; then
|
||||
echo -e " ${GREEN}●${NC} $name (:$port)"
|
||||
else
|
||||
echo -e " ${RED}●${NC} $name (:$port)"
|
||||
STATUS_FAILED=1
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
STATUS_FAILED=0
|
||||
echo "VM:"
|
||||
if is_running; then
|
||||
echo -e " ${GREEN}●${NC} emulator"
|
||||
else
|
||||
echo -e " ${RED}●${NC} emulator"
|
||||
STATUS_FAILED=1
|
||||
fi
|
||||
echo ""
|
||||
echo "Services:"
|
||||
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"
|
||||
}
|
||||
|
||||
cmd_bench() {
|
||||
local elapsed
|
||||
cmd_stop >/dev/null 2>&1 || true
|
||||
SECONDS=0
|
||||
cmd_start
|
||||
elapsed="$SECONDS"
|
||||
printf "Startup time: %.1fs\n" "$elapsed"
|
||||
}
|
||||
|
||||
ACTION="start"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
start|stop|reset|status|bench)
|
||||
ACTION="$1"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [start|stop|reset|status|bench]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$ACTION" in
|
||||
start) cmd_start ;;
|
||||
stop) cmd_stop ;;
|
||||
reset) cmd_reset ;;
|
||||
status) cmd_status ;;
|
||||
bench) cmd_bench ;;
|
||||
esac
|
||||
31
docker/local-emulator/start-app.sh
Normal file
31
docker/local-emulator/start-app.sh
Normal file
@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# In deps-only mode (used during QEMU image build), skip app startup entirely.
|
||||
# The build only needs the infrastructure services to initialize; the app
|
||||
# requires runtime env vars that are not available at build time.
|
||||
if [ "${STACK_DEPS_ONLY:-false}" = "true" ]; then
|
||||
echo "Deps-only mode: app startup skipped."
|
||||
while true; do sleep 3600; done
|
||||
fi
|
||||
|
||||
# Wait for all infrastructure services to be ready before running migrations
|
||||
# and starting the backend/dashboard.
|
||||
INIT_SERVICES_DONE_FILE=/var/run/stack-local-init-services.done
|
||||
INIT_SERVICES_FAILED_FILE=/var/run/stack-local-init-services.failed
|
||||
|
||||
until pg_isready -h 127.0.0.1 -p 5432 -U postgres >/dev/null 2>&1; do sleep 2; done
|
||||
until curl -sf http://127.0.0.1:8123/ping >/dev/null 2>&1; do sleep 2; done
|
||||
until curl -sf http://127.0.0.1:8071/api/v1/health/ >/dev/null 2>&1; do sleep 2; done
|
||||
until curl -sf http://127.0.0.1:9090/minio/health/live >/dev/null 2>&1; do sleep 2; done
|
||||
until [ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/ 2>/dev/null || true)" = "401" ]; do sleep 2; done
|
||||
|
||||
until [ -f "$INIT_SERVICES_DONE_FILE" ]; do
|
||||
if [ -f "$INIT_SERVICES_FAILED_FILE" ]; then
|
||||
echo "init-services.sh failed; refusing to start the app." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
exec /app-entrypoint.sh
|
||||
148
docker/local-emulator/supervisord.conf
Normal file
148
docker/local-emulator/supervisord.conf
Normal file
@ -0,0 +1,148 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
logfile=/var/log/supervisor/supervisord.log
|
||||
pidfile=/var/run/supervisord.pid
|
||||
loglevel=info
|
||||
|
||||
; --- PostgreSQL ---
|
||||
|
||||
[program:postgres]
|
||||
command=/usr/lib/postgresql/16/bin/postgres
|
||||
-D /data/postgres
|
||||
-c listen_addresses=*
|
||||
-c max_connections=500
|
||||
-c shared_preload_libraries=pg_stat_statements
|
||||
-c pg_stat_statements.track=all
|
||||
-c statement_timeout=30s
|
||||
user=postgres
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=10
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; --- Redis ---
|
||||
|
||||
[program:redis]
|
||||
command=/usr/bin/redis-server
|
||||
--port 6379
|
||||
--dir /data/redis
|
||||
--save 60 500
|
||||
--appendonly yes
|
||||
--appendfsync everysec
|
||||
--requirepass PASSWORD-PLACEHOLDER--oVn8GSD6b9
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=10
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; --- Inbucket ---
|
||||
|
||||
[program:inbucket]
|
||||
command=/opt/inbucket/bin/inbucket
|
||||
environment=
|
||||
INBUCKET_SMTP_ADDR="0.0.0.0:2500",
|
||||
INBUCKET_WEB_ADDR="0.0.0.0:9001",
|
||||
INBUCKET_POP3_ADDR="0.0.0.0:1100",
|
||||
INBUCKET_STORAGE_TYPE="file",
|
||||
INBUCKET_STORAGE_PARAMS="path:/data/inbucket"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=20
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; --- ClickHouse ---
|
||||
|
||||
[program:clickhouse]
|
||||
command=/usr/bin/clickhouse-server --config-file=/etc/clickhouse-server/config.xml
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=20
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; --- MinIO ---
|
||||
|
||||
[program:minio]
|
||||
command=/usr/local/bin/minio server /data/minio --address :9090 --console-address :9091
|
||||
environment=
|
||||
MINIO_ROOT_USER="s3mockroot",
|
||||
MINIO_ROOT_PASSWORD="s3mockroot"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=20
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; --- Svix ---
|
||||
|
||||
[program:svix]
|
||||
command=/usr/local/bin/svix-server
|
||||
environment=
|
||||
WAIT_FOR="true",
|
||||
SVIX_DB_DSN="postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@127.0.0.1:5432/svix",
|
||||
SVIX_REDIS_DSN="redis://:PASSWORD-PLACEHOLDER--oVn8GSD6b9@127.0.0.1:6379",
|
||||
SVIX_CACHE_TYPE="memory",
|
||||
SVIX_JWT_SECRET="secret",
|
||||
SVIX_LOG_LEVEL="info",
|
||||
SVIX_QUEUE_TYPE="redis"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=30
|
||||
startsecs=5
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; --- QStash ---
|
||||
|
||||
[program:qstash]
|
||||
command=/usr/local/bin/qstash dev
|
||||
environment=HOST_ON_HOST="host.docker.internal"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=30
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; --- Post-startup init ---
|
||||
|
||||
[program:init-services]
|
||||
command=/init-services.sh
|
||||
autostart=true
|
||||
autorestart=false
|
||||
startsecs=0
|
||||
exitcodes=0
|
||||
priority=50
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; --- Stack Auth backend + dashboard ---
|
||||
|
||||
[program:stack-app]
|
||||
command=/start-app.sh
|
||||
autostart=true
|
||||
autorestart=unexpected
|
||||
startsecs=0
|
||||
priority=60
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
@ -67,13 +67,16 @@ fi
|
||||
|
||||
# ============= ENV VARS =============
|
||||
|
||||
# Create a working directory for our processed files
|
||||
# This is necessary because we need to replace the env vars in all files and we might want to run the seed script multiple times with different env vars.
|
||||
WORK_DIR="/tmp/processed"
|
||||
# Create a working directory for our processed files.
|
||||
# Keep this off /tmp so local-emulator config sharing can bind-mount /tmp
|
||||
# without pushing the whole runtime copy step onto the host filesystem.
|
||||
WORK_DIR="${STACK_RUNTIME_WORK_DIR:-/var/tmp/stack-runtime}"
|
||||
mkdir -p "$WORK_DIR"
|
||||
|
||||
echo "Copying files to working directory..."
|
||||
cp -vr /app/. "$WORK_DIR"/.
|
||||
if [ "$WORK_DIR" != "/app" ]; then
|
||||
echo "Copying files to working directory..."
|
||||
cp -r /app/. "$WORK_DIR"/.
|
||||
fi
|
||||
|
||||
# Find all files in the apps directory that contain a STACK_ENV_VAR_SENTINEL and extract the unique sentinel strings.
|
||||
echo "Finding unhandled sentinels..."
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { ALL_APPS, AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
|
||||
import { AppIcon, appSquarePaddingExpression, appSquareWidthExpression } from "@stackframe/stack-shared/dist/apps/apps-ui";
|
||||
import { BarChart3, ClipboardList, CreditCard, KeyRound, Mail, Mails, Rocket, ShieldEllipsis, Sparkles, Triangle, Tv, UserCog, Users, Vault, Webhook } from "lucide-react";
|
||||
import { BarChart3, ClipboardList, CreditCard, KeyRound, Mail, Mails, Rocket, ShieldCheck, ShieldEllipsis, Sparkles, Triangle, Tv, UserCog, Users, Vault, Webhook } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "../../lib/cn";
|
||||
|
||||
@ -25,6 +25,7 @@ const APP_ICONS: Record<AppId, React.FunctionComponent<React.SVGProps<SVGSVGElem
|
||||
webhooks: Webhook,
|
||||
"tv-mode": Tv,
|
||||
"launch-checklist": Rocket,
|
||||
"fraud-protection": ShieldCheck,
|
||||
catalyst: Sparkles,
|
||||
neon: createSvgIcon(() => (
|
||||
<path d="M 21.9999 3.6667 L 21.9999 16.1666 A 1.6667 1.6667 90 0 1 20.3333 17.8333 A 2.5 2.5 90 0 1 18.6666 16.9999 L 12.8333 10.3333 L 12.8333 20.3333 A 1.6667 1.6667 90 0 1 11.1666 21.9999 L 3.6667 21.9999 A 1.6667 1.6667 90 0 1 2 20.3333 L 2 3.6667 A 1.6667 1.6667 90 0 1 3.6667 2 L 20.3333 2 A 1.6667 1.6667 90 0 1 21.9999 3.6667 Z" />
|
||||
|
||||
80
examples/demo/src/app/api/emulator-status/route.ts
Normal file
80
examples/demo/src/app/api/emulator-status/route.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type ServiceCheck = {
|
||||
name: string;
|
||||
description: string;
|
||||
port: number;
|
||||
protocol: 'http' | 'tcp';
|
||||
httpPath?: string;
|
||||
};
|
||||
|
||||
const SERVICES: ServiceCheck[] = [
|
||||
{
|
||||
name: 'Stack Dashboard',
|
||||
description: 'Dashboard UI',
|
||||
port: 26700,
|
||||
protocol: 'http',
|
||||
httpPath: '/handler/sign-in',
|
||||
},
|
||||
{
|
||||
name: 'Stack Backend',
|
||||
description: 'API server',
|
||||
port: 26701,
|
||||
protocol: 'http',
|
||||
httpPath: '/health?db=1',
|
||||
},
|
||||
{
|
||||
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: '/',
|
||||
},
|
||||
];
|
||||
|
||||
async function checkHttp(port: number, path: string, timeoutMs = 3000): Promise<{ up: boolean; latencyMs: number }> {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const res = await fetch(`http://127.0.0.1:${port}${path}`, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
return { up: res.ok || res.status < 500, latencyMs: Math.round(performance.now() - start) };
|
||||
} catch {
|
||||
return { up: false, latencyMs: Math.round(performance.now() - start) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const results = await Promise.all(
|
||||
SERVICES.map(async (svc) => {
|
||||
const check = await checkHttp(svc.port, svc.httpPath ?? '/');
|
||||
return {
|
||||
name: svc.name,
|
||||
description: svc.description,
|
||||
port: svc.port,
|
||||
status: check.up ? 'up' as const : 'down' as const,
|
||||
latencyMs: check.latencyMs,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
timestamp: new Date().toISOString(),
|
||||
services: results,
|
||||
summary: {
|
||||
total: results.length,
|
||||
up: results.filter((r) => r.status === 'up').length,
|
||||
down: results.filter((r) => r.status === 'down').length,
|
||||
},
|
||||
});
|
||||
}
|
||||
188
examples/demo/src/app/emulator-status/page.tsx
Normal file
188
examples/demo/src/app/emulator-status/page.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises';
|
||||
import { Card, CardContent, CardHeader, Typography } from '@stackframe/stack-ui';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
type ServiceResult = {
|
||||
name: string;
|
||||
description: string;
|
||||
port: number;
|
||||
status: 'up' | 'down';
|
||||
latencyMs: number;
|
||||
};
|
||||
|
||||
type StatusResponse = {
|
||||
timestamp: string;
|
||||
services: ServiceResult[];
|
||||
summary: { total: number; up: number; down: number };
|
||||
};
|
||||
|
||||
function StatusDot({ status }: { status: 'up' | 'down' | 'checking' }) {
|
||||
const color = status === 'up'
|
||||
? 'bg-emerald-500'
|
||||
: status === 'down'
|
||||
? 'bg-red-500'
|
||||
: 'bg-yellow-400 animate-pulse';
|
||||
return (
|
||||
<span className={`inline-block w-3 h-3 rounded-full ${color}`} />
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceRow({ service }: { service: ServiceResult }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-3 px-4 border-b last:border-b-0 dark:border-gray-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot status={service.status} />
|
||||
<div>
|
||||
<span className="font-medium text-sm">{service.name}</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">{service.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="font-mono">:{service.port}</span>
|
||||
{service.status === 'up' && (
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{service.latencyMs}ms</span>
|
||||
)}
|
||||
<span className={`px-2 py-0.5 rounded font-medium ${
|
||||
service.status === 'up'
|
||||
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300'
|
||||
: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
|
||||
}`}>
|
||||
{service.status === 'up' ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EmulatorStatusPage() {
|
||||
const [data, setData] = useState<StatusResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/emulator-status', { cache: 'no-store' });
|
||||
const json = await res.json();
|
||||
setData(json as StatusResponse);
|
||||
} catch {
|
||||
// keep last known state
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
runAsynchronously(fetchStatus());
|
||||
if (!autoRefresh) return;
|
||||
const interval = setInterval(() => {
|
||||
runAsynchronously(fetchStatus());
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchStatus, autoRefresh]);
|
||||
|
||||
const summary = data?.summary;
|
||||
const allUp = summary != null && summary.down === 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-start w-full p-6 gap-6">
|
||||
<div className="max-w-2xl w-full space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Typography type="h3">Local Emulator Status</Typography>
|
||||
<Typography className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Monitoring services in the all-in-one dependencies container
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-gray-500 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Auto-refresh
|
||||
</label>
|
||||
<button
|
||||
onClick={() => runAsynchronously(fetchStatus())}
|
||||
disabled={loading}
|
||||
className="text-xs px-3 py-1.5 rounded border dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
{loading && !data ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot status="checking" />
|
||||
<Typography className="text-sm">Checking services...</Typography>
|
||||
</div>
|
||||
) : summary ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusDot status={allUp ? 'up' : 'down'} />
|
||||
<Typography className="text-sm font-medium">
|
||||
{allUp
|
||||
? 'All services operational'
|
||||
: `${summary.down} of ${summary.total} services are offline`}
|
||||
</Typography>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-medium">{summary.up} up</span>
|
||||
{summary.down > 0 && (
|
||||
<span className="text-red-600 dark:text-red-400 font-medium">{summary.down} down</span>
|
||||
)}
|
||||
<span>updated {new Date(data.timestamp).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Typography type="h4">Services</Typography>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{data?.services.map((svc) => (
|
||||
<ServiceRow key={svc.name} service={svc} />
|
||||
))}
|
||||
{!data && loading && (
|
||||
<div className="p-4 text-center text-sm text-gray-400">Loading...</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Typography type="h4">Quick Start</Typography>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<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">
|
||||
{`# Pull the latest image and start the emulator
|
||||
pnpm run emulator:start
|
||||
|
||||
# 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">
|
||||
Dashboard: localhost:26700 | Backend: localhost:26701
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -24,6 +24,14 @@
|
||||
"codegen": "pnpm pre && turbo run codegen && pnpm run generate-sdks",
|
||||
"codegen:backend": "pnpm pre && turbo run codegen --filter=@stackframe/backend...",
|
||||
"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: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",
|
||||
|
||||
138
packages/stack-cli/src/commands/emulator.ts
Normal file
138
packages/stack-cli/src/commands/emulator.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { Command } from "commander";
|
||||
import { execFileSync, spawn } from "child_process";
|
||||
import { existsSync, mkdirSync, renameSync, unlinkSync } from "fs";
|
||||
import { join, resolve } from "path";
|
||||
import { CliError } from "../lib/errors.js";
|
||||
|
||||
function gh(args: string[]): string {
|
||||
try {
|
||||
return execFileSync("gh", args, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && "stderr" in err && typeof err.stderr === "string") {
|
||||
throw new CliError(`GitHub CLI error: ${err.stderr}`);
|
||||
}
|
||||
throw new CliError("GitHub CLI (gh) is required. Install: https://cli.github.com/");
|
||||
}
|
||||
}
|
||||
|
||||
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("Could not find QEMU emulator directory. Run this from the stack-auth repo root.");
|
||||
}
|
||||
|
||||
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 imageDir = join(findQemuDir(), "images");
|
||||
mkdirSync(imageDir, { recursive: true });
|
||||
const dest = join(imageDir, asset);
|
||||
const tmpDest = `${dest}.download`;
|
||||
|
||||
console.log(`Pulling ${asset} from release ${tag}...`);
|
||||
try {
|
||||
execFileSync("gh", ["release", "download", tag, "--repo", repo, "--pattern", asset, "--output", tmpDest, "--clobber"], { stdio: "inherit" });
|
||||
} catch (err) {
|
||||
if (existsSync(tmpDest)) unlinkSync(tmpDest);
|
||||
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");
|
||||
|
||||
emulator
|
||||
.command("pull")
|
||||
.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: 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 = 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 (default: current system arch). Non-native uses software emulation and is significantly slower.")
|
||||
.action(async (opts) => {
|
||||
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...");
|
||||
pullRelease(arch);
|
||||
}
|
||||
await runEmulator("start", { EMULATOR_ARCH: arch });
|
||||
});
|
||||
|
||||
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: stack-auth/stack-auth)")
|
||||
.action((opts) => {
|
||||
const repo = opts.repo ?? "stack-auth/stack-auth";
|
||||
console.log(`Available emulator releases from ${repo}:\n`);
|
||||
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);
|
||||
});
|
||||
}
|
||||
@ -9,6 +9,7 @@ import { registerExecCommand } from "./commands/exec.js";
|
||||
import { registerConfigCommand } from "./commands/config-file.js";
|
||||
import { registerInitCommand } from "./commands/init.js";
|
||||
import { registerProjectCommand } from "./commands/project.js";
|
||||
import { registerEmulatorCommand } from "./commands/emulator.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@ -29,6 +30,7 @@ registerExecCommand(program);
|
||||
registerConfigCommand(program);
|
||||
registerInitCommand(program);
|
||||
registerProjectCommand(program);
|
||||
registerEmulatorCommand(program);
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
|
||||
@ -54,6 +54,12 @@ export const ALL_APPS = {
|
||||
tags: ["auth", "security"],
|
||||
stage: "stable",
|
||||
},
|
||||
"fraud-protection": {
|
||||
displayName: "Fraud Protection",
|
||||
subtitle: "Protect your project from fraud and abuse",
|
||||
tags: ["auth", "security"],
|
||||
stage: "stable",
|
||||
},
|
||||
"onboarding": {
|
||||
displayName: "Onboarding",
|
||||
subtitle: "Configure user onboarding requirements",
|
||||
|
||||
@ -18,7 +18,7 @@ import { TeamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams";
|
||||
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
|
||||
import type { RestrictedReason } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { InternalSession } from "@stackframe/stack-shared/dist/sessions";
|
||||
import { encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes";
|
||||
import { decodeBase32, encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes";
|
||||
import { scrambleDuringCompileTime } from "@stackframe/stack-shared/dist/utils/compile-time";
|
||||
import { isBrowserLike } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { StackAssertionError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
@ -535,6 +535,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
this._urlOptions = resolvedOptions.urls ?? {};
|
||||
this._oauthScopesOnSignIn = resolvedOptions.oauthScopesOnSignIn ?? {};
|
||||
this._prefetchCrossDomainHandoffParamsIfNeeded();
|
||||
if (isBrowserLike() && (resolvedOptions.tokenStore === "cookie" || resolvedOptions.tokenStore === "nextjs-cookie")) {
|
||||
runAsynchronously(this._trustedParentDomainCache.getOrWait([window.location.hostname], "write-only"));
|
||||
this._ensureCrossSubdomainCookieExists();
|
||||
}
|
||||
|
||||
if (extraOptions && extraOptions.uniqueIdentifier) {
|
||||
this._uniqueIdentifier = extraOptions.uniqueIdentifier;
|
||||
@ -620,6 +624,15 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
const encoded = encodeBase32(new TextEncoder().encode(domain.toLowerCase()));
|
||||
return `${this._refreshTokenCookieName}--custom-${encoded}`;
|
||||
}
|
||||
private _getDomainFromCustomRefreshCookieName(name: string): string | null {
|
||||
const prefix = `${this._refreshTokenCookieName}--custom-`;
|
||||
if (!name.startsWith(prefix)) return null;
|
||||
try {
|
||||
return new TextDecoder().decode(decodeBase32(name.slice(prefix.length)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
private _formatRefreshCookieValue(refreshToken: string, updatedAt: number): string {
|
||||
return JSON.stringify({
|
||||
refresh_token: refreshToken,
|
||||
@ -763,6 +776,26 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
cookieNamesToDelete: [...cookieNames],
|
||||
};
|
||||
}
|
||||
|
||||
private _ensureCrossSubdomainCookieExists() {
|
||||
runAsynchronously(async () => {
|
||||
const hostname = window.location.hostname;
|
||||
const domain = await this._trustedParentDomainCache.getOrWait([hostname], "read-write");
|
||||
if (domain.status === "error" || !domain.data) {
|
||||
return;
|
||||
}
|
||||
const cookies = this._getAllBrowserCookies();
|
||||
const customCookieName = this._getCustomRefreshCookieName(domain.data);
|
||||
if (cookies[customCookieName]) {
|
||||
return;
|
||||
}
|
||||
const { refreshToken, updatedAt } = this._extractRefreshTokenFromCookieMap(cookies);
|
||||
if (refreshToken && updatedAt) {
|
||||
const value = this._formatRefreshCookieValue(refreshToken, updatedAt);
|
||||
setOrDeleteCookieClient(customCookieName, value, { maxAge: 60 * 60 * 24 * 365, domain: domain.data });
|
||||
}
|
||||
});
|
||||
}
|
||||
private _queueCustomRefreshCookieUpdate(refreshToken: string | null, updatedAt: number | null, context: "browser" | "server") {
|
||||
runAsynchronously(async () => {
|
||||
this._mostRecentQueuedCookieRefreshIndex++;
|
||||
@ -855,7 +888,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
);
|
||||
setOrDeleteCookieClient(defaultName, refreshCookieValue, { maxAge: 60 * 60 * 24 * 365, secure });
|
||||
setOrDeleteCookieClient(this._accessTokenCookieName, accessTokenPayload, { maxAge: 60 * 60 * 24 });
|
||||
cookieNamesToDelete.forEach((name) => deleteCookieClient(name, {}));
|
||||
cookieNamesToDelete.forEach((name) => {
|
||||
const domain = this._getDomainFromCustomRefreshCookieName(name);
|
||||
deleteCookieClient(name, domain ? { domain } : {});
|
||||
});
|
||||
this._queueCustomRefreshCookieUpdate(refreshToken, updatedAt, "browser");
|
||||
hasSucceededInWriting = true;
|
||||
} catch (e) {
|
||||
@ -912,9 +948,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
]);
|
||||
if (cookieNamesToDelete.length > 0) {
|
||||
await Promise.all(
|
||||
cookieNamesToDelete.map((name) =>
|
||||
deleteCookie(name, { noOpIfServerComponent: true }),
|
||||
),
|
||||
cookieNamesToDelete.map((name) => {
|
||||
const domain = this._getDomainFromCustomRefreshCookieName(name);
|
||||
return deleteCookie(name, { noOpIfServerComponent: true, ...(domain ? { domain } : {}) });
|
||||
}),
|
||||
);
|
||||
}
|
||||
this._queueCustomRefreshCookieUpdate(refreshToken, updatedAt, "server");
|
||||
|
||||
@ -95,7 +95,7 @@ importers:
|
||||
version: 0.20.3(typescript@5.9.3)
|
||||
turbo:
|
||||
specifier: ^2.8.15
|
||||
version: 2.8.15
|
||||
version: 2.8.17
|
||||
typescript:
|
||||
specifier: 5.9.3
|
||||
version: 5.9.3
|
||||
@ -4987,14 +4987,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'}
|
||||
@ -13816,10 +13808,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}
|
||||
@ -16311,41 +16299,41 @@ packages:
|
||||
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
turbo-darwin-64@2.8.15:
|
||||
resolution: {integrity: sha512-EElCh+Ltxex9lXYrouV3hHjKP3HFP31G91KMghpNHR/V99CkFudRcHcnWaorPbzAZizH1m8o2JkLL8rptgb8WQ==}
|
||||
turbo-darwin-64@2.8.17:
|
||||
resolution: {integrity: sha512-ZFkv2hv7zHpAPEXBF6ouRRXshllOavYc+jjcrYyVHvxVTTwJWsBZwJ/gpPzmOKGvkSjsEyDO5V6aqqtZzwVF+Q==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
turbo-darwin-arm64@2.8.15:
|
||||
resolution: {integrity: sha512-ORmvtqHiHwvNynSWvLIleyU8dKtwQ4ILk39VsEwfKSEzSHWYWYxZhBmD9GAGRPlNl7l7S1irrziBlDEGVpq+vQ==}
|
||||
turbo-darwin-arm64@2.8.17:
|
||||
resolution: {integrity: sha512-5DXqhQUt24ycEryXDfMNKEkW5TBHs+QmU23a2qxXwwFDaJsWcPo2obEhBxxdEPOv7qmotjad+09RGeWCcJ9JDw==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
turbo-linux-64@2.8.15:
|
||||
resolution: {integrity: sha512-Bk1E61a+PCWUTfhqfXFlhEJMLp6nak0J0Qt14IZX1og1zyaiBLkM6M1GQFbPpiWfbUcdLwRaYQhO0ySB07AJ8w==}
|
||||
turbo-linux-64@2.8.17:
|
||||
resolution: {integrity: sha512-KLUbz6w7F73D/Ihh51hVagrKR0/CTsPEbRkvXLXvoND014XJ4BCrQUqSxlQ4/hu+nqp1v5WlM85/h3ldeyujuA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
turbo-linux-arm64@2.8.15:
|
||||
resolution: {integrity: sha512-3BX0Vk+XkP0uiZc8pkjQGNsAWjk5ojC53bQEMp6iuhSdWpEScEFmcT6p7DL7bcJmhP2mZ1HlAu0A48wrTGCtvg==}
|
||||
turbo-linux-arm64@2.8.17:
|
||||
resolution: {integrity: sha512-pJK67XcNJH40lTAjFu7s/rUlobgVXyB3A3lDoq+/JccB3hf+SysmkpR4Itlc93s8LEaFAI4mamhFuTV17Z6wOg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
turbo-stream@2.4.0:
|
||||
resolution: {integrity: sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==}
|
||||
|
||||
turbo-windows-64@2.8.15:
|
||||
resolution: {integrity: sha512-m14ogunMF+grHZ1jzxSCO6q0gEfF1tmr+0LU+j1QNd/M1X33tfKnQqmpkeUR/REsGjfUlkQlh6PAzqlT3cA3Pg==}
|
||||
turbo-windows-64@2.8.17:
|
||||
resolution: {integrity: sha512-EijeQ6zszDMmGZLP2vT2RXTs/GVi9rM0zv2/G4rNu2SSRSGFapgZdxgW4b5zUYLVaSkzmkpWlGfPfj76SW9yUg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
turbo-windows-arm64@2.8.15:
|
||||
resolution: {integrity: sha512-HWh6dnzhl7nu5gRwXeqP61xbyDBNmQ4UCeWNa+si4/6RAtHlKEcZTNs7jf4U+oqBnbtv4uxbKZZPf/kN0EK4+A==}
|
||||
turbo-windows-arm64@2.8.17:
|
||||
resolution: {integrity: sha512-crpfeMPkfECd4V1PQ/hMoiyVcOy04+bWedu/if89S15WhOalHZ2BYUi6DOJhZrszY+mTT99OwpOsj4wNfb/GHQ==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
turbo@2.8.15:
|
||||
resolution: {integrity: sha512-ERZf7pKOR155NKs/PZt1+83NrSEJfUL7+p9/TGZg/8xzDVMntXEFQlX4CsNJQTyu4h3j+dZYiQWOOlv5pssuHQ==}
|
||||
turbo@2.8.17:
|
||||
resolution: {integrity: sha512-YwPsNSqU2f/RXU/+Kcb7cPkPZARxom4+me7LKEdN5jsvy2tpfze3zDZ4EiGrJnvOm9Avu9rK0aaYsP7qZ3iz7A==}
|
||||
hasBin: true
|
||||
|
||||
type-check@0.4.0:
|
||||
@ -20152,12 +20140,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
|
||||
@ -26130,7 +26112,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':
|
||||
@ -30370,7 +30352,7 @@ snapshots:
|
||||
|
||||
glob@13.0.0:
|
||||
dependencies:
|
||||
minimatch: 10.1.1
|
||||
minimatch: 10.2.4
|
||||
minipass: 7.1.2
|
||||
path-scurry: 2.0.0
|
||||
|
||||
@ -32075,10 +32057,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
|
||||
@ -35324,34 +35302,34 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 1.14.1
|
||||
|
||||
turbo-darwin-64@2.8.15:
|
||||
turbo-darwin-64@2.8.17:
|
||||
optional: true
|
||||
|
||||
turbo-darwin-arm64@2.8.15:
|
||||
turbo-darwin-arm64@2.8.17:
|
||||
optional: true
|
||||
|
||||
turbo-linux-64@2.8.15:
|
||||
turbo-linux-64@2.8.17:
|
||||
optional: true
|
||||
|
||||
turbo-linux-arm64@2.8.15:
|
||||
turbo-linux-arm64@2.8.17:
|
||||
optional: true
|
||||
|
||||
turbo-stream@2.4.0: {}
|
||||
|
||||
turbo-windows-64@2.8.15:
|
||||
turbo-windows-64@2.8.17:
|
||||
optional: true
|
||||
|
||||
turbo-windows-arm64@2.8.15:
|
||||
turbo-windows-arm64@2.8.17:
|
||||
optional: true
|
||||
|
||||
turbo@2.8.15:
|
||||
turbo@2.8.17:
|
||||
optionalDependencies:
|
||||
turbo-darwin-64: 2.8.15
|
||||
turbo-darwin-arm64: 2.8.15
|
||||
turbo-linux-64: 2.8.15
|
||||
turbo-linux-arm64: 2.8.15
|
||||
turbo-windows-64: 2.8.15
|
||||
turbo-windows-arm64: 2.8.15
|
||||
turbo-darwin-64: 2.8.17
|
||||
turbo-darwin-arm64: 2.8.17
|
||||
turbo-linux-64: 2.8.17
|
||||
turbo-linux-arm64: 2.8.17
|
||||
turbo-windows-64: 2.8.17
|
||||
turbo-windows-arm64: 2.8.17
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user