mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-19 21:00:40 +08:00
Merge branch 'dev' into promptless/changelog-oauth-retry-reliability
This commit is contained in:
commit
03aeb1953b
2
.github/workflows/qemu-emulator-build.yaml
vendored
2
.github/workflows/qemu-emulator-build.yaml
vendored
@ -22,6 +22,8 @@ concurrency:
|
||||
|
||||
env:
|
||||
EMULATOR_IMAGE_NAME: stack-local-emulator
|
||||
EMULATOR_IMAGE_DIR: ${{ github.workspace }}/docker/local-emulator/qemu/images
|
||||
EMULATOR_RUN_DIR: ${{ github.workspace }}/docker/local-emulator/qemu/run
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@ -259,6 +259,45 @@ export async function seed() {
|
||||
console.log('Internal team created');
|
||||
}
|
||||
|
||||
// Upsert the internal API key set before any flake-prone work (dummy-project
|
||||
// seed, email/svix, clickhouse). The emulator CLI authenticates against the
|
||||
// internal project using the pck stored here, so it must land before the rest
|
||||
// of the seed even if something later fails.
|
||||
const isLocalEmulator = process.env.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR === 'true';
|
||||
const rawPck = process.env.STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY;
|
||||
if (isLocalEmulator && !rawPck) {
|
||||
// Emulator images build before a per-VM pck is available. Runtime boots set
|
||||
// STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY from the VM-generated
|
||||
// random value and re-run the seed, which upserts the internal key set then.
|
||||
console.log('Skipping internal API key set (no pck provided; emulator mode).');
|
||||
} else {
|
||||
const keySet = {
|
||||
publishableClientKey: rawPck || throwErr('STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY is not set'),
|
||||
secretServerKey: isLocalEmulator
|
||||
? (process.env.STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY ?? null)
|
||||
: (process.env.STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY || throwErr('STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY is not set')),
|
||||
superSecretAdminKey: isLocalEmulator
|
||||
? (process.env.STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY ?? null)
|
||||
: (process.env.STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY || throwErr('STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY is not set')),
|
||||
};
|
||||
|
||||
await globalPrismaClient.apiKeySet.upsert({
|
||||
where: { projectId_id: { projectId: 'internal', id: apiKeyId } },
|
||||
update: {
|
||||
...keySet,
|
||||
},
|
||||
create: {
|
||||
id: apiKeyId,
|
||||
projectId: 'internal',
|
||||
description: "Internal API key set",
|
||||
expiresAt: new Date('2099-12-31T23:59:59Z'),
|
||||
...keySet,
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Updated internal API key set');
|
||||
}
|
||||
|
||||
const shouldSeedDummyProject = process.env.STACK_SEED_ENABLE_DUMMY_PROJECT === 'true';
|
||||
if (shouldSeedDummyProject) {
|
||||
await seedDummyProject({
|
||||
@ -268,28 +307,6 @@ export async function seed() {
|
||||
});
|
||||
}
|
||||
|
||||
const keySet = {
|
||||
publishableClientKey: process.env.STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY || throwErr('STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY is not set'),
|
||||
secretServerKey: process.env.STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY || throwErr('STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY is not set'),
|
||||
superSecretAdminKey: process.env.STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY || throwErr('STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY is not set'),
|
||||
};
|
||||
|
||||
await globalPrismaClient.apiKeySet.upsert({
|
||||
where: { projectId_id: { projectId: 'internal', id: apiKeyId } },
|
||||
update: {
|
||||
...keySet,
|
||||
},
|
||||
create: {
|
||||
id: apiKeyId,
|
||||
projectId: 'internal',
|
||||
description: "Internal API key set",
|
||||
expiresAt: new Date('2099-12-31T23:59:59Z'),
|
||||
...keySet,
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Updated internal API key set');
|
||||
|
||||
// Create optional default admin user if credentials are provided.
|
||||
// This user will be able to login to the dashboard with both email/password and magic link.
|
||||
|
||||
|
||||
@ -130,7 +130,7 @@ export const GET = createSmartRouteHandler({
|
||||
async function processRequest(request: OutgoingRequest): Promise<void> {
|
||||
// Prisma JsonValue doesn't carry a precise shape for this JSON blob.
|
||||
const options = request.qstashOptions as any;
|
||||
const baseUrl = getEnvVariable("NEXT_PUBLIC_STACK_API_URL");
|
||||
const baseUrl = getEnvVariable("NEXT_PUBLIC_SERVER_STACK_API_URL", "") || getEnvVariable("NEXT_PUBLIC_STACK_API_URL");
|
||||
|
||||
let fullUrl = new URL(options.url, baseUrl).toString();
|
||||
|
||||
@ -157,7 +157,7 @@ export const GET = createSmartRouteHandler({
|
||||
function buildUpstashRequest(request: OutgoingRequest): UpstashRequest {
|
||||
// Prisma JsonValue doesn't carry a precise shape for this JSON blob.
|
||||
const options = request.qstashOptions as any;
|
||||
const baseUrl = getEnvVariable("NEXT_PUBLIC_STACK_API_URL");
|
||||
const baseUrl = getEnvVariable("NEXT_PUBLIC_SERVER_STACK_API_URL", "") || getEnvVariable("NEXT_PUBLIC_STACK_API_URL");
|
||||
|
||||
let fullUrl = new URL(options.url, baseUrl).toString();
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { overrideEnvironmentConfigOverride } from "@/lib/config";
|
||||
import {
|
||||
LOCAL_EMULATOR_ADMIN_USER_ID,
|
||||
LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE,
|
||||
@ -58,14 +59,15 @@ async function assertLocalEmulatorOwnerTeamReadiness() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Promise<string> {
|
||||
async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Promise<{ projectId: string, created: boolean }> {
|
||||
const existingRows = await globalPrismaClient.$queryRaw<LocalEmulatorProjectMappingRow[]>(Prisma.sql`
|
||||
SELECT "projectId"
|
||||
FROM "LocalEmulatorProject"
|
||||
WHERE "absoluteFilePath" = ${absoluteFilePath}
|
||||
LIMIT 1
|
||||
`);
|
||||
const projectId = existingRows[0] ? existingRows[0].projectId : generateUuid();
|
||||
const existingRow = existingRows.length > 0 ? existingRows[0] : undefined;
|
||||
const projectId = existingRow ? existingRow.projectId : generateUuid();
|
||||
|
||||
await globalPrismaClient.project.upsert({
|
||||
where: {
|
||||
@ -98,6 +100,25 @@ async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Prom
|
||||
},
|
||||
});
|
||||
|
||||
const created = existingRow === undefined;
|
||||
|
||||
// Seed environment-level defaults BEFORE registering as a LocalEmulatorProject:
|
||||
// once registered, setEnvironmentConfigOverride refuses to write.
|
||||
// - domains.allowLocalhost: fresh emulator projects allow localhost redirects
|
||||
// so developers don't hit "Redirect URL not whitelisted" before configuring
|
||||
// trustedDomains.
|
||||
// - payments.testMode: emulator payments always go through stripe-mock.
|
||||
if (created) {
|
||||
await overrideEnvironmentConfigOverride({
|
||||
projectId,
|
||||
branchId: DEFAULT_BRANCH_ID,
|
||||
environmentConfigOverrideOverride: {
|
||||
"domains.allowLocalhost": true,
|
||||
"payments.testMode": true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await globalPrismaClient.$executeRaw(Prisma.sql`
|
||||
INSERT INTO "LocalEmulatorProject" ("absoluteFilePath", "projectId", "createdAt", "updatedAt")
|
||||
VALUES (${absoluteFilePath}, ${projectId}, NOW(), NOW())
|
||||
@ -107,7 +128,7 @@ async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Prom
|
||||
"updatedAt" = NOW()
|
||||
`);
|
||||
|
||||
return projectId;
|
||||
return { projectId, created };
|
||||
}
|
||||
|
||||
async function getOrCreateCredentials(projectId: string) {
|
||||
@ -142,7 +163,7 @@ async function getOrCreateCredentials(projectId: string) {
|
||||
},
|
||||
});
|
||||
|
||||
if (!keySet.secretServerKey || !keySet.superSecretAdminKey) {
|
||||
if (!keySet.publishableClientKey || !keySet.secretServerKey || !keySet.superSecretAdminKey) {
|
||||
throw new StackAssertionError("Local emulator key set is missing required keys.", {
|
||||
projectId,
|
||||
keySetId: keySet.id,
|
||||
@ -150,6 +171,7 @@ async function getOrCreateCredentials(projectId: string) {
|
||||
}
|
||||
|
||||
return {
|
||||
publishableClientKey: keySet.publishableClientKey,
|
||||
secretServerKey: keySet.secretServerKey,
|
||||
superSecretAdminKey: keySet.superSecretAdminKey,
|
||||
};
|
||||
@ -179,6 +201,7 @@ export const POST = createSmartRouteHandler({
|
||||
bodyType: yupString().oneOf(["json"]).defined(),
|
||||
body: yupObject({
|
||||
project_id: yupString().defined(),
|
||||
publishable_client_key: yupString().defined(),
|
||||
secret_server_key: yupString().defined(),
|
||||
super_secret_admin_key: yupString().defined(),
|
||||
branch_config_override_string: yupString().defined(),
|
||||
@ -215,7 +238,7 @@ export const POST = createSmartRouteHandler({
|
||||
|
||||
await assertLocalEmulatorOwnerTeamReadiness();
|
||||
|
||||
const projectId = await getOrCreateLocalEmulatorProjectId(absoluteFilePath);
|
||||
const { projectId } = await getOrCreateLocalEmulatorProjectId(absoluteFilePath);
|
||||
const credentials = await getOrCreateCredentials(projectId);
|
||||
const fileConfig = await readConfigFromFile(absoluteFilePath);
|
||||
|
||||
@ -224,6 +247,7 @@ export const POST = createSmartRouteHandler({
|
||||
bodyType: "json" as const,
|
||||
body: {
|
||||
project_id: projectId,
|
||||
publishable_client_key: credentials.publishableClientKey,
|
||||
secret_server_key: credentials.secretServerKey,
|
||||
super_secret_admin_key: credentials.superSecretAdminKey,
|
||||
branch_config_override_string: JSON.stringify(fileConfig),
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { isLocalEmulatorEnabled } from "@/lib/local-emulator";
|
||||
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
|
||||
import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
|
||||
|
||||
@ -59,7 +60,7 @@ export const ALLOWED_MODEL_IDS: ReadonlySet<string> = new Set([
|
||||
]);
|
||||
|
||||
export function createOpenRouterProvider() {
|
||||
const baseURL = getNodeEnvironment() === "development"
|
||||
const baseURL = (getNodeEnvironment() === "development" || isLocalEmulatorEnabled())
|
||||
? "http://localhost:8102/api/latest/integrations/ai-proxy/v1"
|
||||
: "https://api.stack-auth.com/api/latest/integrations/ai-proxy/v1";
|
||||
return createOpenRouter({
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { traceSpan } from '@/utils/telemetry';
|
||||
import { runAsynchronouslyAndWaitUntil } from '@/utils/background-tasks';
|
||||
import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env';
|
||||
import { isLocalEmulatorEnabled } from "@/lib/local-emulator";
|
||||
import { StackAssertionError, captureError } from '@stackframe/stack-shared/dist/utils/errors';
|
||||
import { Result } from '@stackframe/stack-shared/dist/utils/results';
|
||||
import { Sandbox } from '@vercel/sandbox';
|
||||
@ -27,11 +28,13 @@ function createFreestyleEngine(): JsEngine {
|
||||
let baseUrl = getEnvVariable("STACK_FREESTYLE_API_ENDPOINT", "") || undefined;
|
||||
|
||||
if (apiKey === "mock_stack_freestyle_key") {
|
||||
if (!["development", "test"].includes(getNodeEnvironment())) {
|
||||
if (!["development", "test"].includes(getNodeEnvironment()) && !isLocalEmulatorEnabled()) {
|
||||
throw new StackAssertionError("Mock Freestyle key used in production; please set the STACK_FREESTYLE_API_KEY environment variable.");
|
||||
}
|
||||
const prefix = getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81");
|
||||
baseUrl = `http://localhost:${prefix}22`;
|
||||
if (!baseUrl) {
|
||||
const prefix = getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81");
|
||||
baseUrl = `http://localhost:${prefix}22`;
|
||||
}
|
||||
}
|
||||
|
||||
const freestyle = new FreestyleClient({
|
||||
@ -147,7 +150,7 @@ export async function executeJavascript(code: string, options: ExecuteJavascript
|
||||
|
||||
return await runWithFallback(code, options);
|
||||
} else {
|
||||
if (getNodeEnvironment().includes("prod")) {
|
||||
if (getNodeEnvironment().includes("prod") && !isLocalEmulatorEnabled()) {
|
||||
throw new StackAssertionError("STACK_VERCEL_SANDBOX_TOKEN is set to the disabled sentinel value in production. Please configure a real Vercel Sandbox token.");
|
||||
}
|
||||
|
||||
|
||||
@ -7,7 +7,6 @@ import type { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/use
|
||||
import type { inlineProductSchema, productSchema, productSchemaWithMetadata } from "@stackframe/stack-shared/dist/schema-fields";
|
||||
import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currency-constants";
|
||||
import { FAR_FUTURE_DATE, addInterval, getIntervalsElapsed } from "@stackframe/stack-shared/dist/utils/dates";
|
||||
import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env";
|
||||
import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import { filterUndefined, getOrUndefined, has, typedEntries, typedFromEntries, typedKeys, typedValues } from "@stackframe/stack-shared/dist/utils/objects";
|
||||
import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings";
|
||||
@ -15,11 +14,9 @@ import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids";
|
||||
import Stripe from "stripe";
|
||||
import * as yup from "yup";
|
||||
import { Tenancy } from "./tenancies";
|
||||
import { getStripeForAccount } from "./stripe";
|
||||
import { getStripeForAccount, useStripeMock } from "./stripe";
|
||||
|
||||
const DEFAULT_PRODUCT_START_DATE = new Date("1973-01-01T12:00:00.000Z"); // monday
|
||||
const stripeSecretKey = getEnvVariable("STACK_STRIPE_SECRET_KEY", "");
|
||||
const useStripeMock = stripeSecretKey === "sk_test_mockstripekey" && ["development", "test"].includes(getNodeEnvironment());
|
||||
|
||||
type Product = yup.InferType<typeof productSchema>;
|
||||
type ProductWithMetadata = yup.InferType<typeof productSchemaWithMetadata>;
|
||||
|
||||
@ -8,15 +8,18 @@ import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dis
|
||||
import { captureError, StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
|
||||
import Stripe from "stripe";
|
||||
import type * as yup from "yup";
|
||||
import { isLocalEmulatorEnabled } from "./local-emulator";
|
||||
import { createStripeProxy, type StripeOverridesMap } from "./stripe-proxy";
|
||||
|
||||
const stripeSecretKey = getEnvVariable("STACK_STRIPE_SECRET_KEY", "");
|
||||
const useStripeMock = stripeSecretKey === "sk_test_mockstripekey" && ["development", "test"].includes(getNodeEnvironment());
|
||||
export const useStripeMock = isLocalEmulatorEnabled()
|
||||
|| (stripeSecretKey === "sk_test_mockstripekey" && ["development", "test"].includes(getNodeEnvironment()));
|
||||
const stackPortPrefix = getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81");
|
||||
const stripeMockPort = Number(getEnvVariable("STACK_STRIPE_MOCK_PORT", "") || `${stackPortPrefix}23`);
|
||||
const stripeConfig: Stripe.StripeConfig = useStripeMock ? {
|
||||
protocol: "http",
|
||||
host: "localhost",
|
||||
port: Number(`${stackPortPrefix}23`),
|
||||
port: stripeMockPort,
|
||||
} : {};
|
||||
|
||||
/** Product type as stored in Stripe metadata (same as config product schema) */
|
||||
|
||||
@ -28,6 +28,12 @@ export async function ensureUpstashSignature(fullReq: SmartRequest): Promise<voi
|
||||
if ((nodeEnv.includes("development") || nodeEnv.includes("test")) && url.hostname === "localhost") {
|
||||
url.hostname = "host.docker.internal";
|
||||
}
|
||||
// The backend binds to 0.0.0.0, so Next.js reports the incoming URL with that
|
||||
// hostname. QStash signs the URL we told it to call (e.g. localhost), so
|
||||
// normalize 0.0.0.0 back to localhost for signature verification.
|
||||
if (url.hostname === "0.0.0.0") {
|
||||
url.hostname = "localhost";
|
||||
}
|
||||
|
||||
const isValid = await upstashReceiver.verify({
|
||||
signature: upstashSignature,
|
||||
|
||||
@ -61,7 +61,9 @@ function PaymentsLayoutInner({ children }: { children: React.ReactNode }) {
|
||||
});
|
||||
};
|
||||
|
||||
if (!stripeAccountInfo) {
|
||||
const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true";
|
||||
|
||||
if (!stripeAccountInfo && !isLocalEmulator) {
|
||||
return (
|
||||
<div className="mx-auto max-w-sm h-full flex items-center">
|
||||
<Card className="w-full">
|
||||
@ -172,7 +174,7 @@ function PaymentsLayoutInner({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !stripeAccountInfo.details_submitted && (
|
||||
) : stripeAccountInfo && !stripeAccountInfo.details_submitted && (
|
||||
<div className="flex justify-center px-4 pt-4 sm:px-6 sm:pt-6">
|
||||
<div className={cn(
|
||||
"w-full max-w-[1250px] rounded-2xl p-4 sm:p-5",
|
||||
@ -236,7 +238,7 @@ function PaymentsLayoutInner({ children }: { children: React.ReactNode }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") !== "true" && (
|
||||
{getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") !== "true" && getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") !== "true" && (
|
||||
<div className={cn(bannerHasItems && "p-4", "flex justify-center")}>
|
||||
<div style={{ maxWidth: 1250, width: '100%' }}>
|
||||
<ConnectNotificationBanner
|
||||
|
||||
@ -8,12 +8,13 @@ import { StripeConnectProvider } from "@/components/payments/stripe-connect-prov
|
||||
|
||||
export default function PageClient() {
|
||||
const isPreview = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") === "true";
|
||||
const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true";
|
||||
|
||||
return (
|
||||
<PageLayout title="Payouts">
|
||||
{isPreview ? (
|
||||
{isPreview || isLocalEmulator ? (
|
||||
<Alert>
|
||||
Payouts are unavailable in preview mode.
|
||||
Payouts are unavailable in {isLocalEmulator ? "the local emulator" : "preview mode"}.
|
||||
</Alert>
|
||||
) : (
|
||||
<StripeConnectProvider>
|
||||
|
||||
@ -211,8 +211,8 @@ export const ToolInvocationCard = memo(function ToolInvocationCard({
|
||||
|
||||
const { label, icon: Icon } = getToolDisplay();
|
||||
|
||||
const input = invocation.input as { query?: string };
|
||||
const queryArg = input.query;
|
||||
const input = invocation.input as { query?: string } | undefined;
|
||||
const queryArg = input?.query;
|
||||
const result = invocation.output as { success?: boolean, result?: unknown[], error?: string, rowCount?: number };
|
||||
|
||||
return (
|
||||
|
||||
@ -13,6 +13,7 @@ import { useEffect } from "react";
|
||||
import { appearanceVariablesForTheme } from "./stripe-theme-variables";
|
||||
|
||||
const isPreview = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") === "true";
|
||||
const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true";
|
||||
|
||||
type StripeConnectProviderProps = {
|
||||
children: React.ReactNode,
|
||||
@ -36,7 +37,7 @@ export function StripeConnectProvider({ children }: StripeConnectProviderProps)
|
||||
const adminApp = useAdminApp();
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const stripeConnectInstance = isPreview ? null : getStripeConnectInstance(adminApp);
|
||||
const stripeConnectInstance = isPreview || isLocalEmulator ? null : getStripeConnectInstance(adminApp);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stripeConnectInstance) return;
|
||||
@ -47,7 +48,7 @@ export function StripeConnectProvider({ children }: StripeConnectProviderProps)
|
||||
});
|
||||
}, [resolvedTheme, stripeConnectInstance]);
|
||||
|
||||
// In preview mode, skip Stripe Connect initialization entirely
|
||||
// In preview/emulator mode, skip Stripe Connect initialization entirely
|
||||
if (!stripeConnectInstance) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@ -52,6 +52,7 @@ COPY docs ./docs
|
||||
|
||||
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
|
||||
ENV NEXT_CONFIG_OUTPUT=standalone
|
||||
ENV NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY=pk_test_mock_publishable_key_for_local_emulator
|
||||
|
||||
# Build the backend NextJS app
|
||||
RUN pnpm turbo run docker-build --filter=@stackframe/backend... --filter=@stackframe/dashboard...
|
||||
@ -87,8 +88,47 @@ RUN cp -a /app/node_modules /pruned-node_modules && \
|
||||
date-fns@2* date-fns@3*
|
||||
|
||||
|
||||
# ── Freestyle mock build ─────────────────────────────────────────────────────
|
||||
|
||||
FROM node-base AS freestyle-mock-builder
|
||||
WORKDIR /freestyle-mock
|
||||
COPY docker/dependencies/freestyle-mock/Dockerfile /tmp/freestyle-mock-dockerfile
|
||||
# Extract the inline package.json and server.mjs from the Dockerfile's RUN cat commands,
|
||||
# then install dependencies. This avoids duplicating the source.
|
||||
RUN node -e " \
|
||||
const fs = require('fs'); \
|
||||
const df = fs.readFileSync('/tmp/freestyle-mock-dockerfile', 'utf8'); \
|
||||
const pkgMatch = df.match(/cat <<'EOF' > package\\.json\\n([\\s\\S]*?)\\nEOF/); \
|
||||
fs.writeFileSync('package.json', pkgMatch[1]); \
|
||||
const srvMatch = df.match(/cat <<'EOF' > server\\.mjs\\n([\\s\\S]*?)\\nEOF/); \
|
||||
let server = srvMatch[1]; \
|
||||
server = server.replace('server.listen(8080)', 'server.listen(process.env.PORT || 8080)'); \
|
||||
server = server.replace( \
|
||||
'from \"fs/promises\"', \
|
||||
'from \"fs/promises\"; import { symlinkSync } from \"fs\"' \
|
||||
); \
|
||||
server = server.replace( \
|
||||
'await mkdir(workDir, { recursive: true });', \
|
||||
'await mkdir(workDir, { recursive: true }); try { symlinkSync(\"/app/freestyle-mock/node_modules\", join(workDir, \"node_modules\")); } catch {}' \
|
||||
); \
|
||||
fs.writeFileSync('server.mjs', server); \
|
||||
"
|
||||
RUN npm install
|
||||
|
||||
|
||||
# ── Mock OAuth server build ───────────────────────────────────────────────────
|
||||
|
||||
FROM node-base AS mock-oauth-builder
|
||||
WORKDIR /mock-oauth
|
||||
COPY apps/mock-oauth-server/package.json .
|
||||
RUN pnpm install && pnpm add esbuild --save-dev
|
||||
COPY apps/mock-oauth-server/src ./src
|
||||
RUN npx esbuild src/index.ts --bundle --platform=node --target=node22 --outfile=dist/index.cjs
|
||||
|
||||
|
||||
# ── Service binary stages ─────────────────────────────────────────────────────
|
||||
|
||||
FROM stripe/stripe-mock:v0.195.0 AS stripe-mock-bin
|
||||
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
|
||||
@ -159,6 +199,9 @@ COPY --from=node-base /usr/local/bin/node /usr/local/bin/node
|
||||
# Inbucket
|
||||
COPY --from=inbucket-bin /opt/inbucket /opt/inbucket
|
||||
|
||||
# Stripe mock
|
||||
COPY --from=stripe-mock-bin /bin/stripe-mock /usr/local/bin/stripe-mock
|
||||
|
||||
# Svix (UPX-compressed)
|
||||
COPY --from=upx-compress /out/svix-server /usr/local/bin/svix-server
|
||||
|
||||
@ -191,6 +234,14 @@ RUN cp -a /app/node_modules /app/node_modules.standalone 2>/dev/null || mkdir -p
|
||||
COPY --from=migration-pruner /pruned-node_modules ./node_modules
|
||||
COPY --from=builder /app/packages ./packages
|
||||
|
||||
# Mock OAuth server (bundled single file)
|
||||
COPY --from=mock-oauth-builder /mock-oauth/dist/index.cjs /app/mock-oauth-server/index.cjs
|
||||
|
||||
# Freestyle mock (JS execution for email rendering)
|
||||
COPY --from=freestyle-mock-builder /freestyle-mock /app/freestyle-mock
|
||||
COPY --from=node-base /usr/local/bin/npm /usr/local/bin/npm
|
||||
COPY --from=node-base /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
|
||||
|
||||
RUN mkdir -p \
|
||||
/data/postgres \
|
||||
/data/redis \
|
||||
@ -207,17 +258,18 @@ RUN mkdir -p \
|
||||
&& chown -R postgres:postgres /data/postgres
|
||||
|
||||
COPY docker/local-emulator/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
COPY docker/local-emulator/run-cron-jobs.sh /run-cron-jobs.sh
|
||||
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
|
||||
RUN chmod +x /entrypoint.sh /init-services.sh /start-app.sh /app-entrypoint.sh /run-cron-jobs.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
|
||||
# Backend: 8102, Dashboard: 8101, Mock OAuth: 8114
|
||||
EXPOSE 5432 6379 2500 9001 1100 8071 8123 9009 9090 8080 8101 8102 8114
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@ -15,6 +15,8 @@
|
||||
|
||||
<max_server_memory_usage_to_ram_ratio>0.5</max_server_memory_usage_to_ram_ratio>
|
||||
|
||||
<custom_settings_prefixes>SQL_</custom_settings_prefixes>
|
||||
|
||||
<user_directories>
|
||||
<users_xml>
|
||||
<path>users.xml</path>
|
||||
|
||||
@ -28,4 +28,11 @@ if [ -z "$(ls -A "$PGDATA" 2>/dev/null)" ]; then
|
||||
gosu postgres "$PG_BIN/pg_ctl" -D "$PGDATA" stop -w
|
||||
fi
|
||||
|
||||
# Generate a fresh CRON_SECRET per container start. The cron endpoints are
|
||||
# internal — nothing outside the container calls them — so we don't want the
|
||||
# baked-in mock value from .env.development to be a usable credential against
|
||||
# a running emulator. Overriding here propagates to both the backend and the
|
||||
# run-cron-jobs.sh loop via supervisord's inherited environment.
|
||||
export CRON_SECRET="$(openssl rand -hex 32)"
|
||||
|
||||
exec /usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf
|
||||
|
||||
@ -90,9 +90,11 @@ const entries = [
|
||||
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"),
|
||||
// STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY is generated per-VM at boot
|
||||
// by docker/local-emulator/qemu/cloud-init/emulator/user-data and injected via
|
||||
// /run/stack-auth/local-emulator.env. SECRET_SERVER_KEY and SUPER_SECRET_ADMIN_KEY
|
||||
// are intentionally omitted so the seed script leaves them null on the internal
|
||||
// project; per-project credentials come from /api/v1/internal/local-emulator/project.
|
||||
blank(),
|
||||
comment("# Third-party/test integrations"),
|
||||
fromSource("apps/backend/.env.development", backendEnv, "STACK_SVIX_API_KEY"),
|
||||
@ -159,7 +161,7 @@ const entries = [
|
||||
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_CLICKHOUSE_DATABASE", "default"),
|
||||
literal("STACK_EMAIL_MONITOR_INBUCKET_API_URL", "http://127.0.0.1:9001"),
|
||||
literal("BACKEND_PORT", "8102"),
|
||||
literal("DASHBOARD_PORT", "8101"),
|
||||
|
||||
@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
# shellcheck source=common.sh
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
IMAGE_DIR="$SCRIPT_DIR/images"
|
||||
IMAGE_DIR="${EMULATOR_IMAGE_DIR:-$HOME/.stack/emulator/images}"
|
||||
CLOUD_INIT_ROOT="$SCRIPT_DIR/cloud-init"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
|
||||
@ -58,7 +58,7 @@ write_files:
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p /mnt/stack-runtime /run/stack-auth
|
||||
mkdir -p /mnt/stack-runtime /run/stack-auth /var/lib/stack-auth
|
||||
runtime_device="$(readlink -f /dev/disk/by-label/STACKCFG)"
|
||||
mountpoint -q /mnt/stack-runtime || mount -o ro "$runtime_device" /mnt/stack-runtime
|
||||
|
||||
@ -67,6 +67,24 @@ write_files:
|
||||
source /mnt/stack-runtime/base.env
|
||||
set +a
|
||||
|
||||
# Generate and persist the internal-project keys on first boot; reuse
|
||||
# across container restarts so the dashboard keeps its internal-project
|
||||
# session. Reset via `stack emulator reset`.
|
||||
#
|
||||
# pck: used by stack-cli to auth against /api/v1/internal/local-emulator/project
|
||||
# ssk/sak: required by the emulator's own dashboard (StackServerApp
|
||||
# construction throws without them). Not used by user-app flows; the
|
||||
# /local-emulator/project route mints separate per-project credentials.
|
||||
umask 077
|
||||
for key in internal-pck internal-ssk internal-sak; do
|
||||
if [ ! -s "/var/lib/stack-auth/$key" ]; then
|
||||
openssl rand -hex 32 > "/var/lib/stack-auth/$key"
|
||||
fi
|
||||
done
|
||||
INTERNAL_PCK="$(cat /var/lib/stack-auth/internal-pck)"
|
||||
INTERNAL_SSK="$(cat /var/lib/stack-auth/internal-ssk)"
|
||||
INTERNAL_SAK="$(cat /var/lib/stack-auth/internal-sak)"
|
||||
|
||||
# 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.
|
||||
@ -78,6 +96,9 @@ write_files:
|
||||
# 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
|
||||
printf 'STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=%s\n' "$INTERNAL_PCK"
|
||||
printf 'STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=%s\n' "$INTERNAL_SSK"
|
||||
printf 'STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=%s\n' "$INTERNAL_SAK"
|
||||
|
||||
# Computed vars — depend on port prefix or deps host
|
||||
# Host-side ports (for browser URLs — browser runs on host, not in VM)
|
||||
@ -108,7 +129,10 @@ write_files:
|
||||
STACK_CLICKHOUSE_URL=http://${DEPS_HOST}:8123
|
||||
STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:${HP_DASHBOARD}/handler/email-verification
|
||||
STACK_EMAIL_MONITOR_INBUCKET_API_URL=http://${DEPS_HOST}:9001
|
||||
STACK_OAUTH_MOCK_URL=http://${HOST_SERVICES_HOST}:${P}14
|
||||
STACK_OAUTH_MOCK_URL=http://localhost:${P}14
|
||||
STACK_FREESTYLE_API_ENDPOINT=http://${DEPS_HOST}:8180
|
||||
STACK_STRIPE_MOCK_PORT=12111
|
||||
NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY=pk_test_mock_publishable_key_for_local_emulator
|
||||
BACKEND_PORT=${P}02
|
||||
DASHBOARD_PORT=${P}01
|
||||
COMPUTED
|
||||
@ -135,20 +159,54 @@ write_files:
|
||||
|
||||
/usr/local/bin/mount-host-fs
|
||||
/usr/local/bin/render-stack-env
|
||||
|
||||
# Publish the internal publishable client key to the host via 9p so the
|
||||
# stack-cli can authenticate its bootstrap call to
|
||||
# /api/v1/internal/local-emulator/project.
|
||||
set -a
|
||||
source /mnt/stack-runtime/runtime.env
|
||||
set +a
|
||||
if [ -n "${STACK_EMULATOR_VM_DIR_HOST:-}" ] && [ -s /var/lib/stack-auth/internal-pck ]; then
|
||||
install -m 0600 /var/lib/stack-auth/internal-pck \
|
||||
"/host${STACK_EMULATOR_VM_DIR_HOST}/internal-pck"
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
# Mirror container stdout/stderr to a host-visible log for debugging.
|
||||
# The container already bind-mounts /host:/host, so we reuse that path.
|
||||
# Falls back to stdout (captured by systemd-journald) when no host log is set.
|
||||
if [ -n "${STACK_EMULATOR_VM_DIR_HOST:-}" ]; then
|
||||
host_log="/host${STACK_EMULATOR_VM_DIR_HOST}/stack.log"
|
||||
: > "$host_log" 2>/dev/null || 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 2>&1 | tee -a "$host_log"
|
||||
else
|
||||
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
|
||||
fi
|
||||
|
||||
- path: /usr/local/bin/wait-for-deps
|
||||
permissions: '0755'
|
||||
@ -231,7 +289,7 @@ write_files:
|
||||
NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL=http://127.0.0.1:8101
|
||||
NEXT_PUBLIC_STACK_SVIX_SERVER_URL=http://localhost:8071
|
||||
NEXT_PUBLIC_STACK_PORT_PREFIX=81
|
||||
STACK_CLICKHOUSE_DATABASE=analytics
|
||||
STACK_CLICKHOUSE_DATABASE=default
|
||||
BACKEND_PORT=8102
|
||||
DASHBOARD_PORT=8101
|
||||
|
||||
@ -369,10 +427,23 @@ write_files:
|
||||
log "Skipping smoke test: build arch is arm64 and cross-arch TCG can't reliably run the backend."
|
||||
else
|
||||
log "Running smoke test on slim image..."
|
||||
# build.env sets NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true, which makes
|
||||
# docker/server/entrypoint.sh require the three internal SEED keys.
|
||||
# At real-VM boot those come from render-stack-env via
|
||||
# /run/stack-auth/local-emulator.env, but that path doesn't run during
|
||||
# the build-time smoke test. Mint throwaway hex keys for this container
|
||||
# only; they must be hex because entrypoint.sh also validates that
|
||||
# before the internal ApiKeySet bootstrap SQL.
|
||||
SMOKE_PCK="$(openssl rand -hex 32)"
|
||||
SMOKE_SSK="$(openssl rand -hex 32)"
|
||||
SMOKE_SAK="$(openssl rand -hex 32)"
|
||||
docker run --rm --name smoke-test \
|
||||
--network host \
|
||||
--env-file /etc/stack-build.env \
|
||||
--env-file /etc/stack-build-computed.env \
|
||||
-e STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY="$SMOKE_PCK" \
|
||||
-e STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY="$SMOKE_SSK" \
|
||||
-e STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY="$SMOKE_SAK" \
|
||||
-e STACK_SKIP_MIGRATIONS=true \
|
||||
-e STACK_SKIP_SEED_SCRIPT=true \
|
||||
-e STACK_RUNTIME_WORK_DIR=/app \
|
||||
|
||||
@ -5,8 +5,8 @@ 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}"
|
||||
IMAGE_DIR="${EMULATOR_IMAGE_DIR:-$HOME/.stack/emulator/images}"
|
||||
RUN_DIR="${EMULATOR_RUN_DIR:-$HOME/.stack/emulator/run}"
|
||||
|
||||
VM_RAM="${EMULATOR_RAM:-4096}"
|
||||
VM_CPUS="${EMULATOR_CPUS:-4}"
|
||||
@ -89,6 +89,7 @@ prepare_runtime_config_iso() {
|
||||
printf "STACK_EMULATOR_BACKEND_HOST_PORT=%s\n" "$EMULATOR_BACKEND_PORT"
|
||||
printf "STACK_EMULATOR_MINIO_HOST_PORT=%s\n" "$EMULATOR_MINIO_PORT"
|
||||
printf "STACK_EMULATOR_INBUCKET_HOST_PORT=%s\n" "$EMULATOR_INBUCKET_PORT"
|
||||
printf "STACK_EMULATOR_VM_DIR_HOST=%s\n" "$VM_DIR"
|
||||
} > "$cfg_dir/runtime.env"
|
||||
cp "$SCRIPT_DIR/../.env.development" "$cfg_dir/base.env"
|
||||
make_iso_from_dir "$cfg_iso" "STACKCFG" "$cfg_dir"
|
||||
@ -201,10 +202,16 @@ build_qemu_cmd() {
|
||||
|
||||
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"
|
||||
# Bind to 127.0.0.1 so the emulator is not reachable from the LAN.
|
||||
netdev+=",hostfwd=tcp:127.0.0.1:${EMULATOR_DASHBOARD_PORT}-:${PORT_PREFIX}01"
|
||||
netdev+=",hostfwd=tcp:127.0.0.1:${EMULATOR_BACKEND_PORT}-:${PORT_PREFIX}02"
|
||||
netdev+=",hostfwd=tcp:127.0.0.1:${EMULATOR_MINIO_PORT}-:9090"
|
||||
netdev+=",hostfwd=tcp:127.0.0.1:${EMULATOR_INBUCKET_PORT}-:9001"
|
||||
# Mock OAuth server: browser redirects land on `localhost:${PORT_PREFIX}14`
|
||||
# (backend sets STACK_OAUTH_MOCK_URL to that value), so we forward host:port
|
||||
# ↔ VM:port on the same number. Collides with pnpm dev, but the two modes
|
||||
# are mutually exclusive.
|
||||
netdev+=",hostfwd=tcp:127.0.0.1:${PORT_PREFIX}14-:${PORT_PREFIX}14"
|
||||
|
||||
QEMU_CMD=(
|
||||
"$qemu_bin"
|
||||
@ -249,7 +256,7 @@ tail_vm_logs() {
|
||||
}
|
||||
|
||||
ensure_ports_free() {
|
||||
local ports=("$EMULATOR_DASHBOARD_PORT" "$EMULATOR_BACKEND_PORT" "$EMULATOR_MINIO_PORT" "$EMULATOR_INBUCKET_PORT")
|
||||
local ports=("$EMULATOR_DASHBOARD_PORT" "$EMULATOR_BACKEND_PORT" "$EMULATOR_MINIO_PORT" "$EMULATOR_INBUCKET_PORT" "${PORT_PREFIX}14")
|
||||
local port
|
||||
for port in "${ports[@]}"; do
|
||||
if lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
|
||||
32
docker/local-emulator/run-cron-jobs.sh
Executable file
32
docker/local-emulator/run-cron-jobs.sh
Executable file
@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# Polls backend cron endpoints in parallel background loops, matching vercel.json cron config.
|
||||
# Replaces the tsx scripts used in dev mode since tsx is not in the final image.
|
||||
|
||||
set -e
|
||||
|
||||
BACKEND_URL="http://127.0.0.1:${BACKEND_PORT:-8102}"
|
||||
|
||||
if [ -z "${CRON_SECRET:-}" ]; then
|
||||
echo "CRON_SECRET is not set; refusing to start cron loops." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Wait for the backend to be ready
|
||||
until curl -fsS "${BACKEND_URL}/health" >/dev/null 2>&1; do sleep 2; done
|
||||
|
||||
echo "Cron jobs started."
|
||||
|
||||
run_loop() {
|
||||
local endpoint="$1"
|
||||
while true; do
|
||||
curl -sf -o /dev/null --max-time 120 "${BACKEND_URL}${endpoint}" \
|
||||
-H "Authorization: Bearer ${CRON_SECRET}" || true
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
run_loop "/api/latest/internal/email-queue-step" &
|
||||
run_loop "/api/latest/internal/external-db-sync/sequencer" &
|
||||
run_loop "/api/latest/internal/external-db-sync/poller" &
|
||||
|
||||
wait
|
||||
@ -50,7 +50,8 @@ environment=
|
||||
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"
|
||||
INBUCKET_STORAGE_PARAMS="path:/data/inbucket",
|
||||
INBUCKET_WEB_UIDIR="/opt/inbucket/ui"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=20
|
||||
@ -120,6 +121,43 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; --- Stripe mock ---
|
||||
|
||||
[program:stripe-mock]
|
||||
command=/usr/local/bin/stripe-mock -port 12111
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=20
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; --- Freestyle mock (JS execution for email rendering) ---
|
||||
|
||||
[program:freestyle-mock]
|
||||
command=/usr/local/bin/node /app/freestyle-mock/server.mjs
|
||||
environment=NODE_PATH="/app/freestyle-mock/node_modules",PORT="8180"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=20
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; --- Mock OAuth server ---
|
||||
|
||||
[program:mock-oauth]
|
||||
command=/usr/local/bin/node /app/mock-oauth-server/index.cjs
|
||||
autostart=true
|
||||
autorestart=true
|
||||
priority=20
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; --- Post-startup init ---
|
||||
|
||||
[program:init-services]
|
||||
@ -134,6 +172,19 @@ stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; --- Cron jobs (email queue, external DB sync) ---
|
||||
|
||||
[program:cron-jobs]
|
||||
command=/run-cron-jobs.sh
|
||||
autostart=true
|
||||
autorestart=true
|
||||
startsecs=0
|
||||
priority=70
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; --- Stack Auth backend + dashboard ---
|
||||
|
||||
[program:stack-app]
|
||||
|
||||
@ -11,14 +11,28 @@ fi
|
||||
|
||||
# ============= ENV VARS =============
|
||||
|
||||
export STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY:-$(openssl rand -base64 32)}
|
||||
export STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY:-$(openssl rand -base64 32)}
|
||||
export STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=${STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY:-$(openssl rand -base64 32)}
|
||||
if [ "$NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR" = "true" ]; then
|
||||
for v in STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY; do
|
||||
if [ -z "${!v:-}" ]; then
|
||||
echo "$v must be set in local-emulator mode (injected by the QEMU VM)." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
export STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY
|
||||
else
|
||||
export STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY:-$(openssl rand -base64 32)}
|
||||
export STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY:-$(openssl rand -base64 32)}
|
||||
export STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=${STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY:-$(openssl rand -base64 32)}
|
||||
fi
|
||||
|
||||
export NEXT_PUBLIC_STACK_PROJECT_ID=internal
|
||||
export NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY}
|
||||
export STACK_SECRET_SERVER_KEY=${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY}
|
||||
export STACK_SUPER_SECRET_ADMIN_KEY=${STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY}
|
||||
if [ -n "${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY:-}" ]; then
|
||||
export STACK_SECRET_SERVER_KEY=${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY}
|
||||
fi
|
||||
if [ -n "${STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY:-}" ]; then
|
||||
export STACK_SUPER_SECRET_ADMIN_KEY=${STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY}
|
||||
fi
|
||||
|
||||
export NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL=${NEXT_PUBLIC_STACK_DASHBOARD_URL}
|
||||
export NEXT_PUBLIC_STACK_PORT_PREFIX=${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}
|
||||
@ -65,6 +79,44 @@ else
|
||||
cd ../..
|
||||
fi
|
||||
|
||||
# ============= LOCAL EMULATOR: BOOTSTRAP INTERNAL API KEY SET =============
|
||||
# The build-time seed ran without any keys (the VM generates random ones on
|
||||
# first boot). The slim image strips apps/backend/dist so we can't re-run the
|
||||
# full seed here. Instead, targeted-upsert the internal api key set with the
|
||||
# VM-supplied keys:
|
||||
# - pck: used by stack-cli to auth against /api/v1/internal/local-emulator/project
|
||||
# - ssk/sak: required by the emulator's own dashboard (StackServerApp ctor
|
||||
# throws without ssk). User-app flows don't use these — per-project
|
||||
# credentials come from the /local-emulator/project route.
|
||||
if [ "$NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR" = "true" ] && [ -n "${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY:-}" ] && [ -n "${STACK_DATABASE_CONNECTION_STRING:-}" ]; then
|
||||
# Validate the keys are hex-only to defuse any SQL-injection risk (the VM
|
||||
# generates them via `openssl rand -hex 32`, so this is an assert, not a filter).
|
||||
for varname in STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY; do
|
||||
val="${!varname:-}"
|
||||
if [ -z "$val" ]; then
|
||||
echo "ERROR: $varname is not set; refusing to bootstrap internal api key set." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! printf '%s' "$val" | grep -Eq '^[0-9a-fA-F]+$'; then
|
||||
echo "ERROR: $varname is not hex-only; refusing to bootstrap internal api key set." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "Bootstrapping internal API key set (emulator runtime)..."
|
||||
psql "$STACK_DATABASE_CONNECTION_STRING" -v ON_ERROR_STOP=1 <<SQL
|
||||
INSERT INTO "ApiKeySet" ("projectId", id, description, "expiresAt", "createdAt", "updatedAt", "publishableClientKey", "secretServerKey", "superSecretAdminKey")
|
||||
VALUES ('internal', '3142e763-b230-44b5-8636-aa62f7489c26', 'Internal API key set', '2099-12-31T23:59:59Z', NOW(), NOW(),
|
||||
'${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY}',
|
||||
'${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY}',
|
||||
'${STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY}')
|
||||
ON CONFLICT ("projectId", id) DO UPDATE SET
|
||||
"publishableClientKey" = EXCLUDED."publishableClientKey",
|
||||
"secretServerKey" = EXCLUDED."secretServerKey",
|
||||
"superSecretAdminKey" = EXCLUDED."superSecretAdminKey",
|
||||
"updatedAt" = NOW();
|
||||
SQL
|
||||
fi
|
||||
|
||||
# ============= ENV VARS =============
|
||||
|
||||
# Create a working directory for our processed files.
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf node_modules && rimraf dist",
|
||||
"build": "tsdown",
|
||||
"build": "tsdown && node scripts/copy-emulator-assets.mjs",
|
||||
"dev": "tsdown --watch",
|
||||
"lint": "eslint --ext .tsx,.ts .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
27
packages/stack-cli/scripts/copy-emulator-assets.mjs
Normal file
27
packages/stack-cli/scripts/copy-emulator-assets.mjs
Normal file
@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "child_process";
|
||||
import { chmodSync, cpSync, mkdirSync } from "fs";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const packageRoot = resolve(__dirname, "..");
|
||||
const qemuSrc = resolve(packageRoot, "../../docker/local-emulator/qemu");
|
||||
const envGenScript = resolve(packageRoot, "../../docker/local-emulator/generate-env-development.mjs");
|
||||
const envSrc = resolve(packageRoot, "../../docker/local-emulator/.env.development");
|
||||
const distDir = join(packageRoot, "dist");
|
||||
const emulatorDist = join(distDir, "emulator");
|
||||
|
||||
execFileSync(process.execPath, [envGenScript], { stdio: "inherit" });
|
||||
|
||||
mkdirSync(emulatorDist, { recursive: true });
|
||||
|
||||
for (const name of ["run-emulator.sh", "common.sh", "cloud-init"]) {
|
||||
cpSync(join(qemuSrc, name), join(emulatorDist, name), { recursive: true });
|
||||
}
|
||||
|
||||
chmodSync(join(emulatorDist, "run-emulator.sh"), 0o755);
|
||||
|
||||
cpSync(envSrc, join(distDir, ".env.development"));
|
||||
|
||||
console.log(`Copied emulator assets into ${emulatorDist} (+ .env.development into ${distDir}).`);
|
||||
@ -1,9 +1,87 @@
|
||||
import { Command } from "commander";
|
||||
import { execFileSync, spawn } from "child_process";
|
||||
import { existsSync, mkdirSync, renameSync, unlinkSync } from "fs";
|
||||
import { join, resolve } from "path";
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { CliError } from "../lib/errors.js";
|
||||
|
||||
const DEFAULT_EMULATOR_BACKEND_PORT = 26701;
|
||||
|
||||
function emulatorBackendPort(): number {
|
||||
const raw = process.env.EMULATOR_BACKEND_PORT;
|
||||
if (!raw) return DEFAULT_EMULATOR_BACKEND_PORT;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||
throw new CliError(`Invalid EMULATOR_BACKEND_PORT: ${raw}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function emulatorHome(): string {
|
||||
return process.env.STACK_EMULATOR_HOME ?? join(homedir(), ".stack", "emulator");
|
||||
}
|
||||
|
||||
function emulatorRunDir(): string {
|
||||
return join(emulatorHome(), "run");
|
||||
}
|
||||
|
||||
function emulatorImageDir(): string {
|
||||
return join(emulatorHome(), "images");
|
||||
}
|
||||
|
||||
function internalPckPath(): string {
|
||||
return join(emulatorRunDir(), "vm", "internal-pck");
|
||||
}
|
||||
|
||||
async function readInternalPck(timeoutMs = 60_000): Promise<string> {
|
||||
const path = internalPckPath();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let delay = 250;
|
||||
while (Date.now() < deadline) {
|
||||
if (existsSync(path)) {
|
||||
const contents = readFileSync(path, "utf-8").trim();
|
||||
if (contents) return contents;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
delay = Math.min(delay * 2, 2000);
|
||||
}
|
||||
throw new CliError(`Timed out waiting for emulator internal publishable client key at ${path}`);
|
||||
}
|
||||
|
||||
type EmulatorCredentials = {
|
||||
project_id: string,
|
||||
publishable_client_key: string,
|
||||
secret_server_key: string,
|
||||
};
|
||||
|
||||
async function fetchEmulatorCredentials(pck: string, backendPort: number, configFile: string): Promise<EmulatorCredentials> {
|
||||
const url = `http://127.0.0.1:${backendPort}/api/v1/internal/local-emulator/project`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Stack-Project-Id": "internal",
|
||||
"X-Stack-Access-Type": "client",
|
||||
"X-Stack-Publishable-Client-Key": pck,
|
||||
},
|
||||
body: JSON.stringify({ absolute_file_path: configFile }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new CliError(`Failed to initialize local emulator project (${res.status}): ${await res.text()}`);
|
||||
}
|
||||
const data = await res.json() as {
|
||||
project_id: string,
|
||||
publishable_client_key: string,
|
||||
secret_server_key: string,
|
||||
};
|
||||
return {
|
||||
project_id: data.project_id,
|
||||
publishable_client_key: data.publishable_client_key,
|
||||
secret_server_key: data.secret_server_key,
|
||||
};
|
||||
}
|
||||
|
||||
function gh(args: string[]): string {
|
||||
try {
|
||||
return execFileSync("gh", args, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
||||
@ -15,27 +93,63 @@ function gh(args: string[]): string {
|
||||
}
|
||||
}
|
||||
|
||||
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 emulatorScriptsDir(): string {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const bundled = join(here, "emulator");
|
||||
if (existsSync(join(bundled, "run-emulator.sh"))) return bundled;
|
||||
const repo = resolve(here, "../../../docker/local-emulator/qemu");
|
||||
if (existsSync(join(repo, "run-emulator.sh"))) return repo;
|
||||
throw new CliError("Emulator scripts not found in CLI bundle.");
|
||||
}
|
||||
|
||||
function emulatorSpawnEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
EMULATOR_RUN_DIR: emulatorRunDir(),
|
||||
EMULATOR_IMAGE_DIR: emulatorImageDir(),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
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], {
|
||||
const scriptsDir = emulatorScriptsDir();
|
||||
mkdirSync(emulatorRunDir(), { recursive: true });
|
||||
mkdirSync(emulatorImageDir(), { recursive: true });
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const child = spawn(join(scriptsDir, "run-emulator.sh"), [action], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, ...env },
|
||||
cwd: qemuDir,
|
||||
env: emulatorSpawnEnv(env),
|
||||
cwd: scriptsDir,
|
||||
});
|
||||
child.on("close", (code) => code === 0 ? resolve() : reject(new CliError(`run-emulator.sh ${action} exited with code ${code}`)));
|
||||
child.on("close", (code) => code === 0 ? resolvePromise() : 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 isEmulatorRunning(): boolean {
|
||||
const scriptsDir = emulatorScriptsDir();
|
||||
try {
|
||||
execFileSync(join(scriptsDir, "run-emulator.sh"), ["status"], {
|
||||
stdio: "pipe",
|
||||
cwd: scriptsDir,
|
||||
env: emulatorSpawnEnv(),
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startEmulator(arch: "arm64" | "amd64"): Promise<void> {
|
||||
mkdirSync(emulatorImageDir(), { recursive: true });
|
||||
const img = join(emulatorImageDir(), `stack-emulator-${arch}.qcow2`);
|
||||
if (!existsSync(img)) {
|
||||
console.log("No emulator image found. Pulling latest...");
|
||||
pullRelease(arch);
|
||||
}
|
||||
await runEmulator("start", { EMULATOR_ARCH: arch });
|
||||
}
|
||||
|
||||
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;
|
||||
@ -47,7 +161,7 @@ function pullRelease(arch: "arm64" | "amd64", opts: { repo?: string; branch?: st
|
||||
const branch = opts.branch ?? "dev";
|
||||
const tag = opts.tag ?? `emulator-${branch}-latest`;
|
||||
const asset = `stack-emulator-${arch}.qcow2`;
|
||||
const imageDir = join(findQemuDir(), "images");
|
||||
const imageDir = emulatorImageDir();
|
||||
mkdirSync(imageDir, { recursive: true });
|
||||
const dest = join(imageDir, asset);
|
||||
const tmpDest = `${dest}.download`;
|
||||
@ -89,7 +203,7 @@ export function registerEmulatorCommand(program: Command) {
|
||||
runId = String(runs[0].databaseId);
|
||||
}
|
||||
|
||||
const imageDir = join(findQemuDir(), "images");
|
||||
const imageDir = emulatorImageDir();
|
||||
mkdirSync(imageDir, { recursive: true });
|
||||
const dest = join(imageDir, `stack-emulator-${arch}.qcow2`);
|
||||
if (existsSync(dest)) unlinkSync(dest);
|
||||
@ -110,14 +224,91 @@ export function registerEmulatorCommand(program: Command) {
|
||||
.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) => {
|
||||
.option("--config-file <path>", "Path to a config file; when set, credentials for this project are printed to stdout as JSON")
|
||||
.action(async (opts: { arch?: string, configFile?: string }) => {
|
||||
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);
|
||||
|
||||
let resolvedConfigFile: string | undefined;
|
||||
if (opts.configFile) {
|
||||
resolvedConfigFile = resolve(opts.configFile);
|
||||
if (!existsSync(resolvedConfigFile)) {
|
||||
throw new CliError(`Config file not found: ${resolvedConfigFile}`);
|
||||
}
|
||||
}
|
||||
await runEmulator("start", { EMULATOR_ARCH: arch });
|
||||
|
||||
if (isEmulatorRunning()) {
|
||||
console.warn("Emulator already running, reusing existing instance.");
|
||||
} else {
|
||||
await startEmulator(arch);
|
||||
}
|
||||
|
||||
if (resolvedConfigFile) {
|
||||
const pck = await readInternalPck();
|
||||
const creds = await fetchEmulatorCredentials(pck, emulatorBackendPort(), resolvedConfigFile);
|
||||
console.log(JSON.stringify(creds, null, 2));
|
||||
}
|
||||
});
|
||||
|
||||
emulator
|
||||
.command("run")
|
||||
.description("Start the emulator, run a command, and stop the emulator when the command exits")
|
||||
.argument("<cmd>", "Command to run (e.g. \"npm run dev\")")
|
||||
.option("--arch <arch>", "Target architecture")
|
||||
.option("--config-file <path>", "Path to a config file; fetches credentials and injects STACK_PROJECT_ID / STACK_PUBLISHABLE_CLIENT_KEY / STACK_SECRET_SERVER_KEY into the child")
|
||||
.action(async (cmd: string, opts: { arch?: string, configFile?: string }) => {
|
||||
const arch = resolveArch(opts.arch);
|
||||
|
||||
let resolvedConfigFile: string | undefined;
|
||||
if (opts.configFile) {
|
||||
resolvedConfigFile = resolve(opts.configFile);
|
||||
if (!existsSync(resolvedConfigFile)) {
|
||||
throw new CliError(`Config file not found: ${resolvedConfigFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
const alreadyRunning = isEmulatorRunning();
|
||||
if (alreadyRunning) {
|
||||
console.log("Emulator already running, reusing existing instance.");
|
||||
} else {
|
||||
await startEmulator(arch);
|
||||
}
|
||||
|
||||
const childEnv: Record<string, string> = { ...process.env as Record<string, string> };
|
||||
if (resolvedConfigFile) {
|
||||
const pck = await readInternalPck();
|
||||
const backendPort = emulatorBackendPort();
|
||||
const creds = await fetchEmulatorCredentials(pck, backendPort, resolvedConfigFile);
|
||||
const apiUrl = `http://127.0.0.1:${backendPort}`;
|
||||
childEnv.STACK_PROJECT_ID = creds.project_id;
|
||||
childEnv.NEXT_PUBLIC_STACK_PROJECT_ID = creds.project_id;
|
||||
childEnv.STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key;
|
||||
childEnv.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key;
|
||||
childEnv.STACK_SECRET_SERVER_KEY = creds.secret_server_key;
|
||||
childEnv.STACK_API_URL = apiUrl;
|
||||
childEnv.NEXT_PUBLIC_STACK_API_URL = apiUrl;
|
||||
}
|
||||
|
||||
const child = spawn(cmd, { shell: true, stdio: "inherit", env: childEnv });
|
||||
|
||||
const forward = (signal: NodeJS.Signals) => () => child.kill(signal);
|
||||
const onSigint = forward("SIGINT");
|
||||
const onSigterm = forward("SIGTERM");
|
||||
process.on("SIGINT", onSigint);
|
||||
process.on("SIGTERM", onSigterm);
|
||||
|
||||
child.on("close", (code) => {
|
||||
process.off("SIGINT", onSigint);
|
||||
process.off("SIGTERM", onSigterm);
|
||||
const exitCode = code ?? 1;
|
||||
if (alreadyRunning) {
|
||||
process.exit(exitCode);
|
||||
} else {
|
||||
console.log("\nStopping emulator...");
|
||||
runEmulator("stop")
|
||||
.catch(() => { /* best-effort stop */ })
|
||||
.finally(() => process.exit(exitCode));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
emulator.command("stop").description("Stop the emulator (data preserved; use 'reset' to clear)").action(() => runEmulator("stop"));
|
||||
|
||||
@ -126,6 +126,7 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
|
||||
|
||||
constructor(options: StackAdminAppConstructorOptions<HasTokenStore, ProjectId>, extraOptions?: { uniqueIdentifier?: string, checkString?: string, interface?: StackAdminInterface }) {
|
||||
const resolvedOptions = resolveConstructorOptions(options);
|
||||
|
||||
const publishableClientKey = resolvedOptions.publishableClientKey ?? getDefaultPublishableClientKey();
|
||||
|
||||
super(resolvedOptions, {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user