mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Refactor local emulator configuration management and remove obsolete file bridge
- Removed the `readConfigFileContentIfExists` and `writeConfigToFile` functions from the local emulator module, simplifying file handling. - Updated the `readConfigFromFile` function to utilize environment variables for configuration content, enhancing flexibility. - Refactored tests to align with the new configuration management approach, ensuring accurate validation of file reading and handling. - Consolidated Docker environment variables into a new `base.env` file for improved organization and maintainability. - Removed the host file bridge script and related logic, streamlining the emulator's architecture and reducing complexity. These changes significantly enhance the local emulator's usability and maintainability for developers.
This commit is contained in:
parent
00106429ad
commit
c2e4698dfc
@ -4,9 +4,7 @@ import {
|
||||
LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE,
|
||||
LOCAL_EMULATOR_OWNER_TEAM_ID,
|
||||
isLocalEmulatorEnabled,
|
||||
readConfigFileContentIfExists,
|
||||
readConfigFromFile,
|
||||
writeConfigToFile,
|
||||
} from "@/lib/local-emulator";
|
||||
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
|
||||
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
|
||||
@ -193,11 +191,6 @@ export const POST = createSmartRouteHandler({
|
||||
|
||||
const absoluteFilePath = path.resolve(req.body.absolute_file_path);
|
||||
|
||||
const fileContent = await readConfigFileContentIfExists(absoluteFilePath) ?? "";
|
||||
if (fileContent.trim() === "") {
|
||||
await writeConfigToFile(absoluteFilePath, {});
|
||||
}
|
||||
|
||||
await assertLocalEmulatorOwnerTeamReadiness();
|
||||
|
||||
const projectId = await getOrCreateLocalEmulatorProjectId(absoluteFilePath);
|
||||
|
||||
@ -11,7 +11,7 @@ import { Result } from "@stackframe/stack-shared/dist/utils/results";
|
||||
import { deindent, stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
|
||||
import * as yup from "yup";
|
||||
import { RawQuery, globalPrismaClient, rawQuery } from "../prisma-client";
|
||||
import { LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE, getLocalEmulatorFilePath, isLocalEmulatorEnabled, isLocalEmulatorProject, readConfigFromFile, writeConfigToFile } from "./local-emulator";
|
||||
import { LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE, isLocalEmulatorEnabled, isLocalEmulatorProject, readConfigFromFile } from "./local-emulator";
|
||||
import { listPermissionDefinitionsFromConfig } from "./permissions";
|
||||
|
||||
type BranchConfigSourceApi = yup.InferType<typeof branchConfigSourceSchema>;
|
||||
@ -266,14 +266,6 @@ export async function setBranchConfigOverride(options: {
|
||||
}): Promise<void> {
|
||||
const newConfig = migrateConfigOverride("branch", options.branchConfigOverride);
|
||||
|
||||
if (isLocalEmulatorEnabled() && await isLocalEmulatorProject(options.projectId)) {
|
||||
const filePath = await getLocalEmulatorFilePath(options.projectId);
|
||||
if (filePath) {
|
||||
await writeConfigToFile(filePath, newConfig);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// large configs make our DB slow; let's prevent them early
|
||||
const newConfigString = JSON.stringify(newConfig);
|
||||
if (newConfigString.length > 1_000_000) {
|
||||
|
||||
@ -1,91 +1,33 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { readConfigFileContentIfExists, readConfigFromFile, writeConfigToFile } from "./local-emulator";
|
||||
import { readConfigFromFile } from "./local-emulator";
|
||||
|
||||
describe("local emulator file bridge", () => {
|
||||
describe("local emulator config", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("reads config files through the host file bridge when configured", async () => {
|
||||
vi.stubEnv("STACK_LOCAL_EMULATOR_FILE_BRIDGE_URL", "http://127.0.0.1:8116");
|
||||
vi.stubEnv("STACK_LOCAL_EMULATOR_FILE_BRIDGE_TOKEN", "bridge-token");
|
||||
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"));
|
||||
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(JSON.stringify({
|
||||
exists: true,
|
||||
content: "export const config = { auth: { allowLocalhost: true } };\n",
|
||||
}), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(readConfigFromFile("/Users/tester/example/stack.config.ts")).resolves.toMatchInlineSnapshot(`
|
||||
await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).resolves.toMatchInlineSnapshot(`
|
||||
{
|
||||
"auth": {
|
||||
"allowLocalhost": true,
|
||||
},
|
||||
}
|
||||
`);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
new URL("/read", "http://127.0.0.1:8116"),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
"X-Stack-Emulator-Token": "bridge-token",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for missing files when the host file bridge reports they do not exist", async () => {
|
||||
vi.stubEnv("STACK_LOCAL_EMULATOR_FILE_BRIDGE_URL", "http://127.0.0.1:8116");
|
||||
vi.stubEnv("STACK_LOCAL_EMULATOR_FILE_BRIDGE_TOKEN", "bridge-token");
|
||||
|
||||
vi.stubGlobal("fetch", vi.fn(async () => {
|
||||
return new Response(JSON.stringify({ exists: false }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
await expect(readConfigFileContentIfExists("/Users/tester/example/missing-stack.config.ts")).resolves.toBeNull();
|
||||
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("writes config files through the host file bridge when configured", async () => {
|
||||
vi.stubEnv("STACK_LOCAL_EMULATOR_FILE_BRIDGE_URL", "http://127.0.0.1:8116");
|
||||
vi.stubEnv("STACK_LOCAL_EMULATOR_FILE_BRIDGE_TOKEN", "bridge-token");
|
||||
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"));
|
||||
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await writeConfigToFile("/Users/tester/example/stack.config.ts", { teams: { enabled: true } });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
new URL("/write", "http://127.0.0.1:8116"),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
path: "/Users/tester/example/stack.config.ts",
|
||||
content: `export const config = ${JSON.stringify({ teams: { enabled: true } }, null, 2)};\n`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).resolves.toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,7 +3,7 @@ import path from "path";
|
||||
import { createJiti } from "jiti";
|
||||
import { isValidConfig } from "@stackframe/stack-shared/dist/config/format";
|
||||
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { globalPrismaClient } from "@/prisma-client";
|
||||
|
||||
export const LOCAL_EMULATOR_ADMIN_USER_ID = "63abbc96-5329-454a-ba56-e0460173c6c1";
|
||||
@ -36,102 +36,17 @@ export async function isLocalEmulatorProject(projectId: string) {
|
||||
return project !== null;
|
||||
}
|
||||
|
||||
export async function getLocalEmulatorFilePath(projectId: string): Promise<string | null> {
|
||||
const result = await globalPrismaClient.localEmulatorProject.findUnique({
|
||||
where: { projectId },
|
||||
select: { absoluteFilePath: true },
|
||||
});
|
||||
return result?.absoluteFilePath ?? null;
|
||||
}
|
||||
|
||||
function getLocalEmulatorFileBridgeConfig() {
|
||||
const url = getEnvVariable("STACK_LOCAL_EMULATOR_FILE_BRIDGE_URL", "");
|
||||
const token = getEnvVariable("STACK_LOCAL_EMULATOR_FILE_BRIDGE_TOKEN", "");
|
||||
if (url === "") {
|
||||
return null;
|
||||
}
|
||||
if (token === "") {
|
||||
throw new StackAssertionError("STACK_LOCAL_EMULATOR_FILE_BRIDGE_TOKEN must be set when STACK_LOCAL_EMULATOR_FILE_BRIDGE_URL is configured.");
|
||||
}
|
||||
return { url, token };
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
async function requestLocalEmulatorFileBridge(pathname: string, body: Record<string, unknown>): Promise<unknown> {
|
||||
const bridgeConfig = getLocalEmulatorFileBridgeConfig();
|
||||
if (bridgeConfig === null) {
|
||||
throw new StackAssertionError("Local emulator file bridge is not configured.");
|
||||
}
|
||||
|
||||
const response = await fetch(new URL(pathname, bridgeConfig.url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Stack-Emulator-Token": bridgeConfig.token,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new StackAssertionError(`Local emulator file bridge request failed: ${response.status} ${responseText || response.statusText}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(responseText) as unknown;
|
||||
} catch {
|
||||
throw new StackAssertionError(`Local emulator file bridge returned invalid JSON for ${pathname}.`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readConfigFileContentIfExists(filePath: string): Promise<string | null> {
|
||||
const bridgeConfig = getLocalEmulatorFileBridgeConfig();
|
||||
if (bridgeConfig !== null) {
|
||||
const responseJson = await requestLocalEmulatorFileBridge("/read", { path: filePath });
|
||||
if (!isObject(responseJson) || typeof responseJson.exists !== "boolean") {
|
||||
throw new StackAssertionError("Local emulator file bridge returned an invalid read response.", { responseJson });
|
||||
}
|
||||
if (!responseJson.exists) {
|
||||
return null;
|
||||
}
|
||||
if (typeof responseJson.content !== "string") {
|
||||
throw new StackAssertionError("Local emulator file bridge read response is missing file content.", { responseJson });
|
||||
}
|
||||
return responseJson.content;
|
||||
}
|
||||
|
||||
try {
|
||||
return await fs.readFile(filePath, "utf-8");
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeConfigFileContent(filePath: string, content: string): Promise<void> {
|
||||
const bridgeConfig = getLocalEmulatorFileBridgeConfig();
|
||||
if (bridgeConfig !== null) {
|
||||
const responseJson = await requestLocalEmulatorFileBridge("/write", { path: filePath, content });
|
||||
if (!isObject(responseJson) || responseJson.ok !== true) {
|
||||
throw new StackAssertionError("Local emulator file bridge returned an invalid write response.", { responseJson });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(filePath, content, "utf-8");
|
||||
}
|
||||
|
||||
export async function readConfigFromFile(filePath: string): Promise<Record<string, unknown>> {
|
||||
const content = await readConfigFileContentIfExists(filePath);
|
||||
if (content === null) {
|
||||
throw new StatusError(StatusError.BadRequest, `Config file not found: ${filePath}`);
|
||||
const configContentBase64 = getEnvVariable("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", "");
|
||||
const content = configContentBase64 !== ""
|
||||
? Buffer.from(configContentBase64, "base64").toString("utf-8")
|
||||
: await fs.readFile(filePath, "utf-8").catch((error: unknown) => {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (content === null || content.trim() === "") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const jiti = createJiti(import.meta.url, { cache: false });
|
||||
@ -142,8 +57,3 @@ export async function readConfigFromFile(filePath: string): Promise<Record<strin
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function writeConfigToFile(filePath: string, config: Record<string, unknown>): Promise<void> {
|
||||
const content = `export const config = ${JSON.stringify(config, null, 2)};\n`;
|
||||
await writeConfigFileContent(filePath, content);
|
||||
}
|
||||
|
||||
66
docker/local-emulator/base.env
Normal file
66
docker/local-emulator/base.env
Normal file
@ -0,0 +1,66 @@
|
||||
NEXT_PUBLIC_STACK_DOCS_BASE_URL=https://docs.stack-auth.com
|
||||
NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true
|
||||
NEXT_PUBLIC_STACK_PROJECT_ID=internal
|
||||
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
|
||||
STACK_SECRET_SERVER_KEY=this-secret-server-key-is-for-local-development-only
|
||||
STACK_SERVER_SECRET=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo
|
||||
STACK_CHANGELOG_URL=https://raw.githubusercontent.com/stack-auth/stack-auth/refs/heads/dev/CHANGELOG.md
|
||||
STACK_SEED_ENABLE_DUMMY_PROJECT=true
|
||||
STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED=true
|
||||
STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED=true
|
||||
STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST=true
|
||||
STACK_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS=github,spotify,google,microsoft
|
||||
STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS=true
|
||||
STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
|
||||
STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=this-secret-server-key-is-for-local-development-only
|
||||
STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=this-super-secret-admin-key-is-for-local-development-only
|
||||
STACK_SVIX_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NTUxNDA2MzksImV4cCI6MTk3MDUwMDYzOSwibmJmIjoxNjU1MTQwNjM5LCJpc3MiOiJzdml4LXNlcnZlciIsInN1YiI6Im9yZ18yM3JiOFlkR3FNVDBxSXpwZ0d3ZFhmSGlyTXUifQ.En8w77ZJWbd0qrMlHHupHUB-4cx17RfzFykseg95SUk
|
||||
STACK_OPENAI_API_KEY=mock_openai_api_key
|
||||
STACK_OPENROUTER_API_KEY=FORWARD_TO_PRODUCTION
|
||||
STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey
|
||||
STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret
|
||||
STACK_RESEND_API_KEY=mock_resend_api_key
|
||||
STACK_RESEND_WEBHOOK_SECRET=mock_resend_webhook_secret
|
||||
STACK_DNSIMPLE_API_TOKEN=mock_dnsimple_api_token
|
||||
STACK_DNSIMPLE_ACCOUNT_ID=mock_dnsimple_account_id
|
||||
STACK_DNSIMPLE_API_BASE_URL=https://api.dnsimple.com/v2
|
||||
STACK_FREESTYLE_API_KEY=mock_stack_freestyle_key
|
||||
STACK_VERCEL_SANDBOX_TOKEN=vercel_sandbox_disabled_for_local_development
|
||||
CRON_SECRET=mock_cron_secret
|
||||
STACK_S3_REGION=us-east-1
|
||||
STACK_S3_ACCESS_KEY_ID=s3mockroot
|
||||
STACK_S3_SECRET_ACCESS_KEY=s3mockroot
|
||||
STACK_S3_BUCKET=stack-storage
|
||||
STACK_S3_PRIVATE_BUCKET=stack-storage-private
|
||||
STACK_AWS_REGION=us-east-1
|
||||
STACK_AWS_ACCESS_KEY_ID=test
|
||||
STACK_AWS_SECRET_ACCESS_KEY=test
|
||||
STACK_QSTASH_TOKEN=eyJVc2VySUQiOiJkZWZhdWx0VXNlciIsIlBhc3N3b3JkIjoiZGVmYXVsdFBhc3N3b3JkIn0=
|
||||
STACK_QSTASH_CURRENT_SIGNING_KEY=sig_7kYjw48mhY7kAjqNGcy6cr29RJ6r
|
||||
STACK_QSTASH_NEXT_SIGNING_KEY=sig_5ZB6DVzB1wjE8S6rZ7eenA8Pdnhs
|
||||
STACK_CLICKHOUSE_ADMIN_USER=stackframe
|
||||
STACK_CLICKHOUSE_ADMIN_PASSWORD=PASSWORD-PLACEHOLDER--9gKyMxJeMx
|
||||
STACK_CLICKHOUSE_EXTERNAL_PASSWORD=PASSWORD-PLACEHOLDER--EZeHscBMzE
|
||||
STACK_EMAIL_PORT=2500
|
||||
STACK_EMAIL_SECURE=false
|
||||
STACK_EMAIL_USERNAME=does-not-matter
|
||||
STACK_EMAIL_PASSWORD=does-not-matter
|
||||
STACK_EMAIL_SENDER=noreply@example.com
|
||||
STACK_DEFAULT_EMAIL_CAPACITY_PER_HOUR=10000
|
||||
STACK_EMAIL_MONITOR_PROJECT_ID=internal
|
||||
STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
|
||||
STACK_EMAIL_MONITOR_RESEND_EMAIL_DOMAIN=stack-generated.example.com
|
||||
STACK_EMAIL_MONITOR_RESEND_EMAIL_API_KEY=this-is-a-fake-key
|
||||
STACK_EMAIL_MONITOR_SECRET_TOKEN=this-secret-token-is-for-local-development-only
|
||||
STACK_EMAIL_MONITOR_USE_INBUCKET=true
|
||||
STACK_FEATUREBASE_JWT_SECRET=secret-value
|
||||
STACK_FORWARD_MOCK_OAUTH_SERVER=false
|
||||
STACK_GITHUB_CLIENT_ID=MOCK
|
||||
STACK_GITHUB_CLIENT_SECRET=MOCK
|
||||
STACK_GOOGLE_CLIENT_ID=MOCK
|
||||
STACK_GOOGLE_CLIENT_SECRET=MOCK
|
||||
STACK_MICROSOFT_CLIENT_ID=MOCK
|
||||
STACK_MICROSOFT_CLIENT_SECRET=MOCK
|
||||
STACK_SPOTIFY_CLIENT_ID=MOCK
|
||||
STACK_SPOTIFY_CLIENT_SECRET=MOCK
|
||||
STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS=true
|
||||
@ -46,87 +46,26 @@ services:
|
||||
volumes:
|
||||
- "${HOME}:${HOME}"
|
||||
- "/tmp:/tmp"
|
||||
env_file: ./base.env
|
||||
environment:
|
||||
# Port-prefixed URLs (host-specific)
|
||||
NEXT_PUBLIC_STACK_PORT_PREFIX: "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}"
|
||||
NEXT_PUBLIC_STACK_API_URL: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02"
|
||||
NEXT_PUBLIC_STACK_DASHBOARD_URL: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01"
|
||||
NEXT_PUBLIC_STACK_DOCS_BASE_URL: "https://docs.stack-auth.com"
|
||||
NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR: "true"
|
||||
NEXT_PUBLIC_STACK_PROJECT_ID: "internal"
|
||||
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: "this-publishable-client-key-is-for-local-development-only"
|
||||
STACK_SECRET_SERVER_KEY: "this-secret-server-key-is-for-local-development-only"
|
||||
STACK_SERVER_SECRET: "23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo"
|
||||
NEXT_PUBLIC_STACK_SVIX_SERVER_URL: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}13"
|
||||
STACK_CHANGELOG_URL: "https://raw.githubusercontent.com/stack-auth/stack-auth/refs/heads/dev/CHANGELOG.md"
|
||||
STACK_SEED_ENABLE_DUMMY_PROJECT: "true"
|
||||
STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED: "true"
|
||||
STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED: "true"
|
||||
STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST: "true"
|
||||
STACK_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS: "github,spotify,google,microsoft"
|
||||
STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS: "true"
|
||||
STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY: "this-publishable-client-key-is-for-local-development-only"
|
||||
STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY: "this-secret-server-key-is-for-local-development-only"
|
||||
STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY: "this-super-secret-admin-key-is-for-local-development-only"
|
||||
STACK_S3_PUBLIC_ENDPOINT: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}21/stack-storage"
|
||||
STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01/handler/email-verification"
|
||||
STACK_OAUTH_MOCK_URL: "http://host.docker.internal:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}14"
|
||||
STACK_GITHUB_CLIENT_ID: "MOCK"
|
||||
STACK_GITHUB_CLIENT_SECRET: "MOCK"
|
||||
STACK_GOOGLE_CLIENT_ID: "MOCK"
|
||||
STACK_GOOGLE_CLIENT_SECRET: "MOCK"
|
||||
STACK_MICROSOFT_CLIENT_ID: "MOCK"
|
||||
STACK_MICROSOFT_CLIENT_SECRET: "MOCK"
|
||||
STACK_SPOTIFY_CLIENT_ID: "MOCK"
|
||||
STACK_SPOTIFY_CLIENT_SECRET: "MOCK"
|
||||
STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS: "true"
|
||||
BACKEND_PORT: "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02"
|
||||
DASHBOARD_PORT: "${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01"
|
||||
# Deps service hostnames (stack-deps container, not localhost)
|
||||
STACK_DATABASE_CONNECTION_STRING: "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@stack-deps:5432/stackframe"
|
||||
STACK_EMAIL_HOST: "stack-deps"
|
||||
STACK_EMAIL_PORT: "2500"
|
||||
STACK_EMAIL_SECURE: "false"
|
||||
STACK_EMAIL_USERNAME: "does-not-matter"
|
||||
STACK_EMAIL_PASSWORD: "does-not-matter"
|
||||
STACK_EMAIL_SENDER: "noreply@example.com"
|
||||
STACK_DEFAULT_EMAIL_CAPACITY_PER_HOUR: "10000"
|
||||
STACK_SVIX_SERVER_URL: "http://stack-deps:8071"
|
||||
STACK_SVIX_API_KEY: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NTUxNDA2MzksImV4cCI6MTk3MDUwMDYzOSwibmJmIjoxNjU1MTQwNjM5LCJpc3MiOiJzdml4LXNlcnZlciIsInN1YiI6Im9yZ18yM3JiOFlkR3FNVDBxSXpwZ0d3ZFhmSGlyTXUifQ.En8w77ZJWbd0qrMlHHupHUB-4cx17RfzFykseg95SUk"
|
||||
STACK_OPENAI_API_KEY: "mock_openai_api_key"
|
||||
STACK_OPENROUTER_API_KEY: "FORWARD_TO_PRODUCTION"
|
||||
STACK_STRIPE_SECRET_KEY: "sk_test_mockstripekey"
|
||||
STACK_STRIPE_WEBHOOK_SECRET: "mock_stripe_webhook_secret"
|
||||
STACK_RESEND_API_KEY: "mock_resend_api_key"
|
||||
STACK_RESEND_WEBHOOK_SECRET: "mock_resend_webhook_secret"
|
||||
STACK_DNSIMPLE_API_TOKEN: "mock_dnsimple_api_token"
|
||||
STACK_DNSIMPLE_ACCOUNT_ID: "mock_dnsimple_account_id"
|
||||
STACK_DNSIMPLE_API_BASE_URL: "https://api.dnsimple.com/v2"
|
||||
STACK_FREESTYLE_API_KEY: "mock_stack_freestyle_key"
|
||||
STACK_VERCEL_SANDBOX_TOKEN: "vercel_sandbox_disabled_for_local_development"
|
||||
CRON_SECRET: "mock_cron_secret"
|
||||
STACK_S3_ENDPOINT: "http://stack-deps:9090"
|
||||
STACK_S3_PUBLIC_ENDPOINT: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}21/stack-storage"
|
||||
STACK_S3_REGION: "us-east-1"
|
||||
STACK_S3_ACCESS_KEY_ID: "s3mockroot"
|
||||
STACK_S3_SECRET_ACCESS_KEY: "s3mockroot"
|
||||
STACK_S3_BUCKET: "stack-storage"
|
||||
STACK_S3_PRIVATE_BUCKET: "stack-storage-private"
|
||||
STACK_AWS_REGION: "us-east-1"
|
||||
STACK_AWS_ACCESS_KEY_ID: "test"
|
||||
STACK_AWS_SECRET_ACCESS_KEY: "test"
|
||||
STACK_QSTASH_URL: "http://stack-deps:8080"
|
||||
STACK_QSTASH_TOKEN: "eyJVc2VySUQiOiJkZWZhdWx0VXNlciIsIlBhc3N3b3JkIjoiZGVmYXVsdFBhc3N3b3JkIn0="
|
||||
STACK_QSTASH_CURRENT_SIGNING_KEY: "sig_7kYjw48mhY7kAjqNGcy6cr29RJ6r"
|
||||
STACK_QSTASH_NEXT_SIGNING_KEY: "sig_5ZB6DVzB1wjE8S6rZ7eenA8Pdnhs"
|
||||
STACK_CLICKHOUSE_URL: "http://stack-deps:8123"
|
||||
STACK_CLICKHOUSE_ADMIN_USER: "stackframe"
|
||||
STACK_CLICKHOUSE_ADMIN_PASSWORD: "PASSWORD-PLACEHOLDER--9gKyMxJeMx"
|
||||
STACK_CLICKHOUSE_EXTERNAL_PASSWORD: "PASSWORD-PLACEHOLDER--EZeHscBMzE"
|
||||
STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL: "http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01/handler/email-verification"
|
||||
STACK_EMAIL_MONITOR_PROJECT_ID: "internal"
|
||||
STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY: "this-publishable-client-key-is-for-local-development-only"
|
||||
STACK_EMAIL_MONITOR_RESEND_EMAIL_DOMAIN: "stack-generated.example.com"
|
||||
STACK_EMAIL_MONITOR_RESEND_EMAIL_API_KEY: "this-is-a-fake-key"
|
||||
STACK_EMAIL_MONITOR_INBUCKET_API_URL: "http://stack-deps:9001"
|
||||
STACK_EMAIL_MONITOR_USE_INBUCKET: "true"
|
||||
STACK_EMAIL_MONITOR_SECRET_TOKEN: "this-secret-token-is-for-local-development-only"
|
||||
STACK_FEATUREBASE_JWT_SECRET: "secret-value"
|
||||
STACK_FORWARD_MOCK_OAUTH_SERVER: "false"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
|
||||
@ -7,7 +7,6 @@ source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
IMAGE_DIR="$SCRIPT_DIR/images"
|
||||
CLOUD_INIT_ROOT="$SCRIPT_DIR/cloud-init"
|
||||
PREPARE_IMAGE_BUNDLE_SCRIPT="$SCRIPT_DIR/prepare-image-bundle.sh"
|
||||
|
||||
DEBIAN_VERSION="${DEBIAN_VERSION:-13}"
|
||||
DISK_SIZE="${EMULATOR_DISK_SIZE:-12G}"
|
||||
@ -140,7 +139,16 @@ prepare_bundle_artifacts() {
|
||||
fi
|
||||
|
||||
log "Creating Docker image bundle (${arch})..."
|
||||
"$PREPARE_IMAGE_BUNDLE_SCRIPT" "$bundle_tgz" "${DOCKER_IMAGES[@]}"
|
||||
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"
|
||||
}
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ chpasswd:
|
||||
stack:stack-emulator
|
||||
expire: false
|
||||
|
||||
ssh_pwauth: true
|
||||
ssh_pwauth: false
|
||||
|
||||
package_update: true
|
||||
package_upgrade: false
|
||||
@ -28,68 +28,6 @@ packages:
|
||||
- qemu-guest-agent
|
||||
|
||||
write_files:
|
||||
- path: /etc/stack-auth/local-emulator.base.env
|
||||
content: |
|
||||
NEXT_PUBLIC_STACK_DOCS_BASE_URL=https://docs.stack-auth.com
|
||||
NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true
|
||||
NEXT_PUBLIC_STACK_PROJECT_ID=internal
|
||||
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
|
||||
STACK_SECRET_SERVER_KEY=this-secret-server-key-is-for-local-development-only
|
||||
STACK_SERVER_SECRET=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo
|
||||
STACK_CHANGELOG_URL=https://raw.githubusercontent.com/stack-auth/stack-auth/refs/heads/dev/CHANGELOG.md
|
||||
STACK_SEED_ENABLE_DUMMY_PROJECT=true
|
||||
STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED=true
|
||||
STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED=true
|
||||
STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST=true
|
||||
STACK_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS=github,spotify,google,microsoft
|
||||
STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS=true
|
||||
STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
|
||||
STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=this-secret-server-key-is-for-local-development-only
|
||||
STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=this-super-secret-admin-key-is-for-local-development-only
|
||||
STACK_SVIX_API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2NTUxNDA2MzksImV4cCI6MTk3MDUwMDYzOSwibmJmIjoxNjU1MTQwNjM5LCJpc3MiOiJzdml4LXNlcnZlciIsInN1YiI6Im9yZ18yM3JiOFlkR3FNVDBxSXpwZ0d3ZFhmSGlyTXUifQ.En8w77ZJWbd0qrMlHHupHUB-4cx17RfzFykseg95SUk
|
||||
STACK_OPENAI_API_KEY=mock_openai_api_key
|
||||
STACK_OPENROUTER_API_KEY=FORWARD_TO_PRODUCTION
|
||||
STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey
|
||||
STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret
|
||||
STACK_RESEND_API_KEY=mock_resend_api_key
|
||||
STACK_RESEND_WEBHOOK_SECRET=mock_resend_webhook_secret
|
||||
STACK_DNSIMPLE_API_TOKEN=mock_dnsimple_api_token
|
||||
STACK_DNSIMPLE_ACCOUNT_ID=mock_dnsimple_account_id
|
||||
STACK_DNSIMPLE_API_BASE_URL=https://api.dnsimple.com/v2
|
||||
STACK_FREESTYLE_API_KEY=mock_stack_freestyle_key
|
||||
STACK_VERCEL_SANDBOX_TOKEN=vercel_sandbox_disabled_for_local_development
|
||||
CRON_SECRET=mock_cron_secret
|
||||
STACK_S3_REGION=us-east-1
|
||||
STACK_S3_ACCESS_KEY_ID=s3mockroot
|
||||
STACK_S3_SECRET_ACCESS_KEY=s3mockroot
|
||||
STACK_S3_BUCKET=stack-storage
|
||||
STACK_S3_PRIVATE_BUCKET=stack-storage-private
|
||||
STACK_AWS_REGION=us-east-1
|
||||
STACK_AWS_ACCESS_KEY_ID=test
|
||||
STACK_AWS_SECRET_ACCESS_KEY=test
|
||||
STACK_QSTASH_TOKEN=eyJVc2VySUQiOiJkZWZhdWx0VXNlciIsIlBhc3N3b3JkIjoiZGVmYXVsdFBhc3N3b3JkIn0=
|
||||
STACK_QSTASH_CURRENT_SIGNING_KEY=sig_7kYjw48mhY7kAjqNGcy6cr29RJ6r
|
||||
STACK_QSTASH_NEXT_SIGNING_KEY=sig_5ZB6DVzB1wjE8S6rZ7eenA8Pdnhs
|
||||
STACK_CLICKHOUSE_ADMIN_USER=stackframe
|
||||
STACK_CLICKHOUSE_ADMIN_PASSWORD=PASSWORD-PLACEHOLDER--9gKyMxJeMx
|
||||
STACK_CLICKHOUSE_EXTERNAL_PASSWORD=PASSWORD-PLACEHOLDER--EZeHscBMzE
|
||||
STACK_EMAIL_MONITOR_PROJECT_ID=internal
|
||||
STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only
|
||||
STACK_EMAIL_MONITOR_RESEND_EMAIL_DOMAIN=stack-generated.example.com
|
||||
STACK_EMAIL_MONITOR_RESEND_EMAIL_API_KEY=this-is-a-fake-key
|
||||
STACK_EMAIL_MONITOR_SECRET_TOKEN=this-secret-token-is-for-local-development-only
|
||||
STACK_FEATUREBASE_JWT_SECRET=secret-value
|
||||
STACK_FORWARD_MOCK_OAUTH_SERVER=false
|
||||
STACK_GITHUB_CLIENT_ID=MOCK
|
||||
STACK_GITHUB_CLIENT_SECRET=MOCK
|
||||
STACK_GOOGLE_CLIENT_ID=MOCK
|
||||
STACK_GOOGLE_CLIENT_SECRET=MOCK
|
||||
STACK_MICROSOFT_CLIENT_ID=MOCK
|
||||
STACK_MICROSOFT_CLIENT_SECRET=MOCK
|
||||
STACK_SPOTIFY_CLIENT_ID=MOCK
|
||||
STACK_SPOTIFY_CLIENT_SECRET=MOCK
|
||||
STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS=true
|
||||
|
||||
- path: /usr/local/bin/install-emulator-containers
|
||||
permissions: '0755'
|
||||
content: |
|
||||
@ -135,103 +73,43 @@ write_files:
|
||||
|
||||
set -a
|
||||
source /mnt/stack-runtime/runtime.env
|
||||
source /etc/stack-auth/local-emulator.base.env
|
||||
source /mnt/stack-runtime/base.env
|
||||
set +a
|
||||
|
||||
# Deps runs in the same VM, so always localhost
|
||||
DEPS_HOST=127.0.0.1
|
||||
P="$STACK_EMULATOR_PORT_PREFIX"
|
||||
|
||||
cat > /run/stack-auth/local-emulator.env <<EOF
|
||||
NEXT_PUBLIC_STACK_PORT_PREFIX=${STACK_EMULATOR_PORT_PREFIX}
|
||||
STACK_LOCAL_EMULATOR_FILE_BRIDGE_URL=${STACK_LOCAL_EMULATOR_FILE_BRIDGE_URL}
|
||||
STACK_LOCAL_EMULATOR_FILE_BRIDGE_TOKEN=${STACK_LOCAL_EMULATOR_FILE_BRIDGE_TOKEN}
|
||||
{
|
||||
# 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
|
||||
NEXT_PUBLIC_STACK_API_URL=http://localhost:${STACK_EMULATOR_PORT_PREFIX}02
|
||||
NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:${STACK_EMULATOR_PORT_PREFIX}01
|
||||
NEXT_PUBLIC_BROWSER_STACK_API_URL=http://localhost:${STACK_EMULATOR_PORT_PREFIX}02
|
||||
NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL=http://localhost:${STACK_EMULATOR_PORT_PREFIX}01
|
||||
NEXT_PUBLIC_SERVER_STACK_API_URL=http://127.0.0.1:${STACK_EMULATOR_PORT_PREFIX}02
|
||||
NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL=http://127.0.0.1:${STACK_EMULATOR_PORT_PREFIX}01
|
||||
NEXT_PUBLIC_STACK_SVIX_SERVER_URL=http://localhost:${STACK_EMULATOR_PORT_PREFIX}13
|
||||
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_EMAIL_PORT=2500
|
||||
STACK_EMAIL_SECURE=false
|
||||
STACK_EMAIL_USERNAME=does-not-matter
|
||||
STACK_EMAIL_PASSWORD=does-not-matter
|
||||
STACK_EMAIL_SENDER=noreply@example.com
|
||||
STACK_DEFAULT_EMAIL_CAPACITY_PER_HOUR=10000
|
||||
STACK_SVIX_SERVER_URL=http://${DEPS_HOST}:8071
|
||||
STACK_S3_ENDPOINT=http://${DEPS_HOST}:9090
|
||||
STACK_S3_PUBLIC_ENDPOINT=http://localhost:${STACK_EMULATOR_PORT_PREFIX}21/stack-storage
|
||||
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:${STACK_EMULATOR_PORT_PREFIX}01/handler/email-verification
|
||||
STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:${P}01/handler/email-verification
|
||||
STACK_EMAIL_MONITOR_INBUCKET_API_URL=http://${DEPS_HOST}:9001
|
||||
STACK_EMAIL_MONITOR_USE_INBUCKET=true
|
||||
STACK_OAUTH_MOCK_URL=http://${DEPS_HOST}:${STACK_EMULATOR_PORT_PREFIX}14
|
||||
BACKEND_PORT=${STACK_EMULATOR_PORT_PREFIX}02
|
||||
DASHBOARD_PORT=${STACK_EMULATOR_PORT_PREFIX}01
|
||||
NEXT_PUBLIC_STACK_DOCS_BASE_URL=${NEXT_PUBLIC_STACK_DOCS_BASE_URL}
|
||||
NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=${NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR}
|
||||
NEXT_PUBLIC_STACK_PROJECT_ID=${NEXT_PUBLIC_STACK_PROJECT_ID}
|
||||
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY}
|
||||
STACK_SECRET_SERVER_KEY=${STACK_SECRET_SERVER_KEY}
|
||||
STACK_SERVER_SECRET=${STACK_SERVER_SECRET}
|
||||
STACK_CHANGELOG_URL=${STACK_CHANGELOG_URL}
|
||||
STACK_SEED_ENABLE_DUMMY_PROJECT=${STACK_SEED_ENABLE_DUMMY_PROJECT}
|
||||
STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED=${STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED}
|
||||
STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED=${STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED}
|
||||
STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST=${STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST}
|
||||
STACK_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS=${STACK_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS}
|
||||
STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS=${STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS}
|
||||
STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY}
|
||||
STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY}
|
||||
STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=${STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY}
|
||||
STACK_SVIX_API_KEY=${STACK_SVIX_API_KEY}
|
||||
STACK_OPENAI_API_KEY=${STACK_OPENAI_API_KEY}
|
||||
STACK_OPENROUTER_API_KEY=${STACK_OPENROUTER_API_KEY}
|
||||
STACK_STRIPE_SECRET_KEY=${STACK_STRIPE_SECRET_KEY}
|
||||
STACK_STRIPE_WEBHOOK_SECRET=${STACK_STRIPE_WEBHOOK_SECRET}
|
||||
STACK_RESEND_API_KEY=${STACK_RESEND_API_KEY}
|
||||
STACK_RESEND_WEBHOOK_SECRET=${STACK_RESEND_WEBHOOK_SECRET}
|
||||
STACK_DNSIMPLE_API_TOKEN=${STACK_DNSIMPLE_API_TOKEN}
|
||||
STACK_DNSIMPLE_ACCOUNT_ID=${STACK_DNSIMPLE_ACCOUNT_ID}
|
||||
STACK_DNSIMPLE_API_BASE_URL=${STACK_DNSIMPLE_API_BASE_URL}
|
||||
STACK_FREESTYLE_API_KEY=${STACK_FREESTYLE_API_KEY}
|
||||
STACK_VERCEL_SANDBOX_TOKEN=${STACK_VERCEL_SANDBOX_TOKEN}
|
||||
CRON_SECRET=${CRON_SECRET}
|
||||
STACK_S3_REGION=${STACK_S3_REGION}
|
||||
STACK_S3_ACCESS_KEY_ID=${STACK_S3_ACCESS_KEY_ID}
|
||||
STACK_S3_SECRET_ACCESS_KEY=${STACK_S3_SECRET_ACCESS_KEY}
|
||||
STACK_S3_BUCKET=${STACK_S3_BUCKET}
|
||||
STACK_S3_PRIVATE_BUCKET=${STACK_S3_PRIVATE_BUCKET}
|
||||
STACK_AWS_REGION=${STACK_AWS_REGION}
|
||||
STACK_AWS_ACCESS_KEY_ID=${STACK_AWS_ACCESS_KEY_ID}
|
||||
STACK_AWS_SECRET_ACCESS_KEY=${STACK_AWS_SECRET_ACCESS_KEY}
|
||||
STACK_QSTASH_TOKEN=${STACK_QSTASH_TOKEN}
|
||||
STACK_QSTASH_CURRENT_SIGNING_KEY=${STACK_QSTASH_CURRENT_SIGNING_KEY}
|
||||
STACK_QSTASH_NEXT_SIGNING_KEY=${STACK_QSTASH_NEXT_SIGNING_KEY}
|
||||
STACK_CLICKHOUSE_ADMIN_USER=${STACK_CLICKHOUSE_ADMIN_USER}
|
||||
STACK_CLICKHOUSE_ADMIN_PASSWORD=${STACK_CLICKHOUSE_ADMIN_PASSWORD}
|
||||
STACK_CLICKHOUSE_EXTERNAL_PASSWORD=${STACK_CLICKHOUSE_EXTERNAL_PASSWORD}
|
||||
STACK_EMAIL_MONITOR_PROJECT_ID=${STACK_EMAIL_MONITOR_PROJECT_ID}
|
||||
STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY=${STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY}
|
||||
STACK_EMAIL_MONITOR_RESEND_EMAIL_DOMAIN=${STACK_EMAIL_MONITOR_RESEND_EMAIL_DOMAIN}
|
||||
STACK_EMAIL_MONITOR_RESEND_EMAIL_API_KEY=${STACK_EMAIL_MONITOR_RESEND_EMAIL_API_KEY}
|
||||
STACK_EMAIL_MONITOR_SECRET_TOKEN=${STACK_EMAIL_MONITOR_SECRET_TOKEN}
|
||||
STACK_FEATUREBASE_JWT_SECRET=${STACK_FEATUREBASE_JWT_SECRET}
|
||||
STACK_FORWARD_MOCK_OAUTH_SERVER=${STACK_FORWARD_MOCK_OAUTH_SERVER}
|
||||
STACK_GITHUB_CLIENT_ID=${STACK_GITHUB_CLIENT_ID}
|
||||
STACK_GITHUB_CLIENT_SECRET=${STACK_GITHUB_CLIENT_SECRET}
|
||||
STACK_GOOGLE_CLIENT_ID=${STACK_GOOGLE_CLIENT_ID}
|
||||
STACK_GOOGLE_CLIENT_SECRET=${STACK_GOOGLE_CLIENT_SECRET}
|
||||
STACK_MICROSOFT_CLIENT_ID=${STACK_MICROSOFT_CLIENT_ID}
|
||||
STACK_MICROSOFT_CLIENT_SECRET=${STACK_MICROSOFT_CLIENT_SECRET}
|
||||
STACK_SPOTIFY_CLIENT_ID=${STACK_SPOTIFY_CLIENT_ID}
|
||||
STACK_SPOTIFY_CLIENT_SECRET=${STACK_SPOTIFY_CLIENT_SECRET}
|
||||
STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS=${STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS}
|
||||
EOF
|
||||
STACK_OAUTH_MOCK_URL=http://${DEPS_HOST}:${P}14
|
||||
BACKEND_PORT=${P}02
|
||||
DASHBOARD_PORT=${P}01
|
||||
COMPUTED
|
||||
} > /run/stack-auth/local-emulator.env
|
||||
|
||||
- path: /usr/local/bin/run-stack-app-container
|
||||
permissions: '0755'
|
||||
@ -296,7 +174,8 @@ write_files:
|
||||
WantedBy=multi-user.target
|
||||
|
||||
runcmd:
|
||||
- mkdir -p /etc/stack-auth
|
||||
- systemctl disable --now ssh || true
|
||||
- systemctl mask ssh || true
|
||||
- bash /usr/local/bin/install-emulator-containers
|
||||
- systemctl daemon-reload
|
||||
- systemctl enable stack-deps.service
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
instance-id: stack-emulator-split
|
||||
local-hostname: stack-emulator
|
||||
@ -1,6 +0,0 @@
|
||||
#cloud-config
|
||||
write_files:
|
||||
- path: /etc/motd
|
||||
content: |
|
||||
Stack Auth local emulator uses the unified cloud-init definition in
|
||||
docker/local-emulator/qemu/cloud-init/emulator/.
|
||||
@ -1,139 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import http from "node:http";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const port = Number.parseInt(process.env.STACK_QEMU_FILE_BRIDGE_PORT ?? "", 10) || 8116;
|
||||
const host = process.env.STACK_QEMU_FILE_BRIDGE_HOST ?? "0.0.0.0";
|
||||
const token = process.env.STACK_QEMU_FILE_BRIDGE_TOKEN ?? "";
|
||||
|
||||
if (token === "") {
|
||||
console.error("STACK_QEMU_FILE_BRIDGE_TOKEN is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const allowedRoots = [os.homedir(), "/tmp"].map((root) => path.resolve(root));
|
||||
|
||||
function isWithinRoot(filePath, rootPath) {
|
||||
return filePath === rootPath || filePath.startsWith(`${rootPath}${path.sep}`);
|
||||
}
|
||||
|
||||
function isAllowedPath(filePath) {
|
||||
return allowedRoots.some((rootPath) => isWithinRoot(filePath, rootPath));
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, body) {
|
||||
res.writeHead(statusCode, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function sendText(res, statusCode, message) {
|
||||
res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end(message);
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
let body = "";
|
||||
for await (const chunk of req) {
|
||||
body += chunk.toString();
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function parseRequestBody(bodyText) {
|
||||
try {
|
||||
const body = JSON.parse(bodyText);
|
||||
return typeof body === "object" && body !== null ? body : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRead(res, body) {
|
||||
const requestedPath = body.path;
|
||||
if (typeof requestedPath !== "string") {
|
||||
sendText(res, 400, "Body must include a string 'path'.");
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.resolve(requestedPath);
|
||||
if (!isAllowedPath(filePath)) {
|
||||
sendText(res, 403, `Path is not allowed: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf-8");
|
||||
sendJson(res, 200, { exists: true, content });
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") {
|
||||
sendJson(res, 200, { exists: false });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWrite(res, body) {
|
||||
const requestedPath = body.path;
|
||||
const content = body.content;
|
||||
if (typeof requestedPath !== "string" || typeof content !== "string") {
|
||||
sendText(res, 400, "Body must include string 'path' and 'content' fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.resolve(requestedPath);
|
||||
if (!isAllowedPath(filePath)) {
|
||||
sendText(res, 403, `Path is not allowed: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, content, "utf-8");
|
||||
sendJson(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.url === "/health") {
|
||||
sendText(res, 200, "ok");
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
sendText(res, 405, "Method not allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.headers["x-stack-emulator-token"] !== token) {
|
||||
sendText(res, 401, "Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
const requestBody = parseRequestBody(await readBody(req));
|
||||
if (requestBody === null) {
|
||||
sendText(res, 400, "Invalid JSON body.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/read") {
|
||||
await handleRead(res, requestBody);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === "/write") {
|
||||
await handleWrite(res, requestBody);
|
||||
return;
|
||||
}
|
||||
|
||||
sendText(res, 404, "Not found");
|
||||
} catch (error) {
|
||||
sendText(res, 500, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`stack-qemu-file-bridge listening on ${host}:${port}`);
|
||||
});
|
||||
@ -1,5 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
exec "$SCRIPT_DIR/prepare-image-bundle.sh" "${1:-}" stack-local-emulator-app
|
||||
@ -1,36 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
OUTPUT_PATH="${1:-}"
|
||||
shift || true
|
||||
|
||||
if [ -z "$OUTPUT_PATH" ] || [ "$#" -eq 0 ]; then
|
||||
echo "Usage: $0 <output-tar.gz> <docker-image> [docker-image...]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "docker is required to package emulator images" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for IMAGE_NAME in "$@"; do
|
||||
if ! docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then
|
||||
cat >&2 <<EOF
|
||||
Missing Docker image: $IMAGE_NAME
|
||||
|
||||
Build the local emulator images first, then rerun the QEMU image build.
|
||||
Expected images:
|
||||
- stack-local-emulator-deps
|
||||
- stack-local-emulator-app
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
mkdir -p "$(dirname "$OUTPUT_PATH")"
|
||||
tmp_output="${OUTPUT_PATH}.tmp"
|
||||
rm -f "$tmp_output"
|
||||
|
||||
docker save "$@" | gzip -c > "$tmp_output"
|
||||
mv "$tmp_output" "$OUTPUT_PATH"
|
||||
@ -7,13 +7,12 @@ source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
IMAGE_DIR="$SCRIPT_DIR/images"
|
||||
RUN_DIR="/tmp/stack-emulator-run"
|
||||
FILE_BRIDGE_SCRIPT="$SCRIPT_DIR/host-file-bridge.mjs"
|
||||
|
||||
VM_RAM="${EMULATOR_RAM:-4096}"
|
||||
VM_CPUS="${EMULATOR_CPUS:-4}"
|
||||
PORT_PREFIX="${PORT_PREFIX:-${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}}"
|
||||
FILE_BRIDGE_PORT="${EMULATOR_FILE_BRIDGE_PORT:-${PORT_PREFIX}16}"
|
||||
READY_TIMEOUT="${EMULATOR_READY_TIMEOUT:-240}"
|
||||
CONFIG_FILE=""
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
@ -26,113 +25,6 @@ warn() { echo -e "${YELLOW}[emulator]${NC} $*"; }
|
||||
err() { echo -e "${RED}[emulator]${NC} $*" >&2; }
|
||||
info() { echo -e "${CYAN}[emulator]${NC} $*"; }
|
||||
|
||||
file_bridge_pidfile() {
|
||||
echo "$RUN_DIR/host-file-bridge.pid"
|
||||
}
|
||||
|
||||
file_bridge_logfile() {
|
||||
echo "$RUN_DIR/host-file-bridge.log"
|
||||
}
|
||||
|
||||
file_bridge_tokenfile() {
|
||||
echo "$RUN_DIR/host-file-bridge.token"
|
||||
}
|
||||
|
||||
ensure_file_bridge_token() {
|
||||
local token_file
|
||||
token_file="$(file_bridge_tokenfile)"
|
||||
# Deterministic token so snapshots can reuse the same value across restarts
|
||||
FILE_BRIDGE_TOKEN="$(printf 'stack-local-emulator-%s' "$PORT_PREFIX" | shasum -a 256 | head -c 48)"
|
||||
mkdir -p "$RUN_DIR"
|
||||
printf "%s" "$FILE_BRIDGE_TOKEN" > "$token_file"
|
||||
}
|
||||
|
||||
is_file_bridge_running() {
|
||||
local pidfile
|
||||
pidfile="$(file_bridge_pidfile)"
|
||||
if [ ! -f "$pidfile" ]; then
|
||||
return 1
|
||||
fi
|
||||
local pid
|
||||
pid="$(cat "$pidfile")"
|
||||
kill -0 "$pid" 2>/dev/null
|
||||
}
|
||||
|
||||
start_file_bridge() {
|
||||
ensure_file_bridge_token
|
||||
if is_file_bridge_running; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ ! -f "$FILE_BRIDGE_SCRIPT" ]; then
|
||||
err "Missing host file bridge script: $FILE_BRIDGE_SCRIPT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local pid
|
||||
pid="$(
|
||||
STACK_QEMU_FILE_BRIDGE_PORT="$FILE_BRIDGE_PORT" \
|
||||
STACK_QEMU_FILE_BRIDGE_HOST="0.0.0.0" \
|
||||
STACK_QEMU_FILE_BRIDGE_TOKEN="$FILE_BRIDGE_TOKEN" \
|
||||
python3 - "$FILE_BRIDGE_SCRIPT" "$(file_bridge_logfile)" <<'PY'
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
script_path = sys.argv[1]
|
||||
log_path = sys.argv[2]
|
||||
|
||||
with open(log_path, "ab", buffering=0) as log_file:
|
||||
process = subprocess.Popen(
|
||||
["node", script_path],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log_file,
|
||||
stderr=log_file,
|
||||
start_new_session=True,
|
||||
env=os.environ.copy(),
|
||||
close_fds=True,
|
||||
)
|
||||
|
||||
print(process.pid)
|
||||
PY
|
||||
)"
|
||||
echo "$pid" > "$(file_bridge_pidfile)"
|
||||
|
||||
local elapsed=0
|
||||
while [ "$elapsed" -lt 15 ]; do
|
||||
if curl -sf "http://127.0.0.1:${FILE_BRIDGE_PORT}/health" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
err "Host file bridge exited unexpectedly."
|
||||
tail -40 "$(file_bridge_logfile)" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
|
||||
err "Timed out waiting for host file bridge on port ${FILE_BRIDGE_PORT}."
|
||||
tail -40 "$(file_bridge_logfile)" 2>/dev/null || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
stop_file_bridge() {
|
||||
local pidfile
|
||||
pidfile="$(file_bridge_pidfile)"
|
||||
if [ ! -f "$pidfile" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pid
|
||||
pid="$(cat "$pidfile")"
|
||||
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 -f "$pidfile" "$(file_bridge_logfile)" "$(file_bridge_tokenfile)"
|
||||
}
|
||||
|
||||
detect_host
|
||||
ARCH="${EMULATOR_ARCH:-$HOST_ARCH}"
|
||||
@ -221,11 +113,13 @@ prepare_runtime_config_iso() {
|
||||
cfg_iso="$(runtime_iso_path)"
|
||||
rm -rf "$cfg_dir"
|
||||
mkdir -p "$cfg_dir"
|
||||
cat > "$cfg_dir/runtime.env" <<EOF
|
||||
STACK_EMULATOR_PORT_PREFIX=$PORT_PREFIX
|
||||
STACK_LOCAL_EMULATOR_FILE_BRIDGE_URL=http://10.0.2.2:${FILE_BRIDGE_PORT}
|
||||
STACK_LOCAL_EMULATOR_FILE_BRIDGE_TOKEN=${FILE_BRIDGE_TOKEN}
|
||||
EOF
|
||||
{
|
||||
printf "STACK_EMULATOR_PORT_PREFIX=%s\n" "$PORT_PREFIX"
|
||||
if [ -n "$CONFIG_FILE" ]; then
|
||||
printf "STACK_LOCAL_EMULATOR_CONFIG_CONTENT=%s\n" "$(base64 < "$CONFIG_FILE")"
|
||||
fi
|
||||
} > "$cfg_dir/runtime.env"
|
||||
cp "$SCRIPT_DIR/../base.env" "$cfg_dir/base.env"
|
||||
make_iso_from_dir "$cfg_iso" "STACKCFG" "$cfg_dir"
|
||||
}
|
||||
|
||||
@ -332,7 +226,6 @@ build_qemu_cmd() {
|
||||
esac
|
||||
|
||||
local netdev="user,id=net0"
|
||||
netdev+=",hostfwd=tcp::${PORT_PREFIX}22-:22"
|
||||
# Deps services
|
||||
netdev+=",hostfwd=tcp::${PORT_PREFIX}28-:5432"
|
||||
netdev+=",hostfwd=tcp::${PORT_PREFIX}29-:2500"
|
||||
@ -393,7 +286,7 @@ tail_vm_logs() {
|
||||
}
|
||||
|
||||
ensure_ports_free() {
|
||||
local ports=("${PORT_PREFIX}01" "${PORT_PREFIX}02" "${PORT_PREFIX}05" "${PORT_PREFIX}13" "${PORT_PREFIX}16" "${PORT_PREFIX}21" "${PORT_PREFIX}25" "${PORT_PREFIX}28" "${PORT_PREFIX}29" "${PORT_PREFIX}30" "${PORT_PREFIX}36" "${PORT_PREFIX}37")
|
||||
local ports=("${PORT_PREFIX}01" "${PORT_PREFIX}02" "${PORT_PREFIX}05" "${PORT_PREFIX}13" "${PORT_PREFIX}21" "${PORT_PREFIX}25" "${PORT_PREFIX}28" "${PORT_PREFIX}29" "${PORT_PREFIX}30" "${PORT_PREFIX}36" "${PORT_PREFIX}37")
|
||||
local port
|
||||
for port in "${ports[@]}"; do
|
||||
if lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
@ -435,9 +328,13 @@ stop_vm() {
|
||||
}
|
||||
|
||||
cmd_start() {
|
||||
if [ -n "$CONFIG_FILE" ] && [ ! -f "$CONFIG_FILE" ]; then
|
||||
err "Config file not found: $CONFIG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ensure_ports_free
|
||||
mkdir -p "$RUN_DIR"
|
||||
start_file_bridge
|
||||
|
||||
IS_SNAPSHOT_RESTORE=false
|
||||
|
||||
@ -477,7 +374,6 @@ cmd_start() {
|
||||
|
||||
cmd_stop() {
|
||||
stop_vm
|
||||
stop_file_bridge
|
||||
log "QEMU emulator stopped."
|
||||
}
|
||||
|
||||
@ -513,7 +409,6 @@ cmd_status() {
|
||||
print_service_status "Backend" "${PORT_PREFIX}02" http "/health?db=1"
|
||||
print_service_status "PostgreSQL" "${PORT_PREFIX}28" tcp
|
||||
print_service_status "Inbucket HTTP" "${PORT_PREFIX}05" http /
|
||||
print_service_status "Host File Bridge" "${FILE_BRIDGE_PORT}" http /health
|
||||
print_service_status "Svix" "${PORT_PREFIX}13" http /api/v1/health/
|
||||
print_service_status "MinIO" "${PORT_PREFIX}21" http /minio/health/live
|
||||
print_service_status "QStash" "${PORT_PREFIX}25" http / 401
|
||||
@ -541,7 +436,24 @@ print(f"Startup time: {end_time - start_time:.1f}s")
|
||||
PY
|
||||
}
|
||||
|
||||
ACTION="${1:-start}"
|
||||
ACTION="start"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--config-file)
|
||||
CONFIG_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
start|stop|reset|status|bench)
|
||||
ACTION="$1"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [start|stop|reset|status|bench] [--config-file <path>]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$ACTION" in
|
||||
start) cmd_start ;;
|
||||
@ -549,8 +461,4 @@ case "$ACTION" in
|
||||
reset) cmd_reset ;;
|
||||
status) cmd_status ;;
|
||||
bench) cmd_bench ;;
|
||||
*)
|
||||
echo "Usage: $0 [start|stop|reset|status|bench]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
@ -137,7 +137,7 @@ export default function EmulatorStatusPage() {
|
||||
{summary.down > 0 && (
|
||||
<span className="text-red-600 dark:text-red-400 font-medium">{summary.down} down</span>
|
||||
)}
|
||||
{data != null && <span>updated {new Date(data.timestamp).toLocaleTimeString()}</span>}
|
||||
<span>updated {new Date(data.timestamp).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user