Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/backend/src/lib/local-emulator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { globalPrismaClient } from "@/prisma-client";
import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering";
import { isValidConfig } from "@stackframe/stack-shared/dist/config/format";
import { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD } from "@stackframe/stack-shared/dist/local-emulator";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import fs from "fs/promises";
Expand All @@ -9,8 +10,7 @@ import path from "path";

export const LOCAL_EMULATOR_ADMIN_USER_ID = "63abbc96-5329-454a-ba56-e0460173c6c1";
export const LOCAL_EMULATOR_OWNER_TEAM_ID = "5a0c858b-d9e9-49d4-9943-8ce385d86428";
export const LOCAL_EMULATOR_ADMIN_EMAIL = "local-emulator@stack-auth.com";
export const LOCAL_EMULATOR_ADMIN_PASSWORD = "LocalEmulatorPassword";
export { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD };

export const LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE =
"Environment configuration overrides cannot be changed in the local emulator. Update this in your production deployment instead.";
Expand Down
5 changes: 3 additions & 2 deletions apps/dashboard/src/app/(main)/(protected)/layout-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { CursorBlastEffect } from "@stackframe/dashboard-ui-components";
import { ConfigUpdateDialogProvider } from "@/lib/config-update";
import { getPublicEnvVar } from '@/lib/env';
import { useStackApp, useUser } from "@stackframe/stack";
import { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD } from "@stackframe/stack-shared/dist/local-emulator";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import { useEffect } from "react";
Expand All @@ -20,8 +21,8 @@ export default function LayoutClient({ children }: { children: React.ReactNode }
if (user) return;
if (isLocalEmulator) {
await app.signInWithCredential({
email: "local-emulator@stack-auth.com",
password: "LocalEmulatorPassword",
email: LOCAL_EMULATOR_ADMIN_EMAIL,
password: LOCAL_EMULATOR_ADMIN_PASSWORD,
});
} else if (isPreview) {
const id = generateUuid();
Expand Down
139 changes: 126 additions & 13 deletions apps/e2e/tests/general/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { Result } from "@stackframe/stack-shared/dist/utils/results";
import { describe, beforeAll, afterAll } from "vitest";
import { it, niceFetch, STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_SERVER_KEY, STACK_INTERNAL_PROJECT_ADMIN_KEY } from "../helpers";

const isLocalEmulator = process.env.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR === "true";

const CLI_BIN = path.resolve("packages/stack-cli/dist/index.js");
const CLI_SRC_BIN = path.resolve("packages/stack-cli/src/index.ts");

Expand Down Expand Up @@ -134,6 +136,9 @@ describe("Stack CLI", () => {
});

it("errors when no project ID given", async ({ expect }) => {
// Exercise the default (local) path: project-ID resolution happens before
// any emulator I/O, so the missing-ID error fires regardless of whether
// an emulator is running.
const { stderr, exitCode } = await runCli(["exec", "return 1"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("No project ID");
Expand Down Expand Up @@ -183,7 +188,7 @@ describe("Stack CLI", () => {
it("returns basic expression", async ({ expect }) => {
expect(createdProjectId).toBeDefined();
const { stdout, exitCode } = await runCli(
["exec", "return 1+1"],
["exec", "--cloud", "return 1+1"],
{ STACK_PROJECT_ID: createdProjectId },
);
expect(exitCode).toBe(0);
Expand All @@ -192,7 +197,7 @@ describe("Stack CLI", () => {

it("has stackServerApp object available", async ({ expect }) => {
const { stdout, exitCode } = await runCli(
["exec", "return typeof stackServerApp"],
["exec", "--cloud", "return typeof stackServerApp"],
{ STACK_PROJECT_ID: createdProjectId },
);
expect(exitCode).toBe(0);
Expand All @@ -205,15 +210,21 @@ describe("Stack CLI", () => {
expect(stdout).toContain("https://docs.stack-auth.com/docs/sdk");
});

it("exec help mentions --cloud option", async ({ expect }) => {
const { stdout, exitCode } = await runCli(["exec", "--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("--cloud");
});

it("errors when no javascript is provided", async ({ expect }) => {
const { stderr, exitCode } = await runCli(["exec"], { STACK_PROJECT_ID: createdProjectId });
const { stderr, exitCode } = await runCli(["exec", "--cloud"], { STACK_PROJECT_ID: createdProjectId });
expect(exitCode).toBe(1);
expect(stderr).toContain("Missing JavaScript argument");
});

it("reports syntax error", async ({ expect }) => {
const { stderr, exitCode } = await runCli(
["exec", "return @@invalid"],
["exec", "--cloud", "return @@invalid"],
{ STACK_PROJECT_ID: createdProjectId },
);
expect(exitCode).toBe(1);
Expand All @@ -222,7 +233,7 @@ describe("Stack CLI", () => {

it("reports runtime error", async ({ expect }) => {
const { stderr, exitCode } = await runCli(
["exec", "throw new Error('boom')"],
["exec", "--cloud", "throw new Error('boom')"],
{ STACK_PROJECT_ID: createdProjectId },
);
expect(exitCode).toBe(1);
Expand All @@ -231,7 +242,7 @@ describe("Stack CLI", () => {

it("reports string runtime error", async ({ expect }) => {
const { stderr, exitCode } = await runCli(
["exec", "throw 'boom-string'"],
["exec", "--cloud", "throw 'boom-string'"],
{ STACK_PROJECT_ID: createdProjectId },
);
expect(exitCode).toBe(1);
Expand All @@ -240,7 +251,7 @@ describe("Stack CLI", () => {

it("reports object runtime error", async ({ expect }) => {
const { stderr, exitCode } = await runCli(
["exec", "throw { code: 123 }"],
["exec", "--cloud", "throw { code: 123 }"],
{ STACK_PROJECT_ID: createdProjectId },
);
expect(exitCode).toBe(1);
Expand All @@ -249,7 +260,7 @@ describe("Stack CLI", () => {

it("reports undefined variable", async ({ expect }) => {
const { stderr, exitCode } = await runCli(
["exec", "return nonExistentVar"],
["exec", "--cloud", "return nonExistentVar"],
{ STACK_PROJECT_ID: createdProjectId },
);
expect(exitCode).toBe(1);
Expand All @@ -258,7 +269,7 @@ describe("Stack CLI", () => {

it("returns undefined for no return value", async ({ expect }) => {
const { stdout, exitCode } = await runCli(
["exec", "const x = 1"],
["exec", "--cloud", "const x = 1"],
{ STACK_PROJECT_ID: createdProjectId },
);
expect(exitCode).toBe(0);
Expand All @@ -267,7 +278,7 @@ describe("Stack CLI", () => {

it("returns complex object as JSON", async ({ expect }) => {
const { stdout, exitCode } = await runCli(
["exec", "return {a: 1, b: [2, 3]}"],
["exec", "--cloud", "return {a: 1, b: [2, 3]}"],
{ STACK_PROJECT_ID: createdProjectId },
);
expect(exitCode).toBe(0);
Expand All @@ -277,7 +288,7 @@ describe("Stack CLI", () => {

it("supports async code", async ({ expect }) => {
const { stdout, exitCode } = await runCli(
["exec", "return await Promise.resolve(42)"],
["exec", "--cloud", "return await Promise.resolve(42)"],
{ STACK_PROJECT_ID: createdProjectId },
);
expect(exitCode).toBe(0);
Expand All @@ -290,7 +301,7 @@ describe("Stack CLI", () => {
createdUserEmail = `exec-test-${crypto.randomUUID()}@stack-generated.example.com`;
const code = `const u = await stackServerApp.createUser({ primaryEmail: "${createdUserEmail}", password: "test123456" }); return { id: u.id, email: u.primaryEmail }`;
const { stdout, exitCode } = await runCli(
["exec", code],
["exec", "--cloud", code],
{ STACK_PROJECT_ID: createdProjectId },
);
expect(exitCode).toBe(0);
Expand All @@ -303,14 +314,116 @@ describe("Stack CLI", () => {
expect(createdProjectId).toBeDefined();
expect(createdUserEmail).toBeDefined();
const { stdout, exitCode } = await runCli(
["exec", "const users = await stackServerApp.listUsers(); return users.length"],
["exec", "--cloud", "const users = await stackServerApp.listUsers(); return users.length"],
{ STACK_PROJECT_ID: createdProjectId },
);
expect(exitCode).toBe(0);
const count = JSON.parse(stdout);
expect(count).toBeGreaterThanOrEqual(1);
});

it("local-default exec errors when emulator PCK file is missing", async ({ expect }) => {
expect(createdProjectId).toBeDefined();
// Without --cloud, exec defaults to the local emulator. With
// STACK_EMULATOR_HOME pointed at an empty dir, the PCK file lookup fires
// before any network call and we get a clear error. Setting
// STACK_EMULATOR_READY_TIMEOUT_MS=0 disables the boot-race polling window
// so this test fails fast.
const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-fake-emulator-"));
try {
const { stderr, exitCode } = await runCli(
["exec", "return 1"],
{
STACK_PROJECT_ID: createdProjectId,
STACK_EMULATOR_HOME: fakeEmulatorHome,
STACK_EMULATOR_READY_TIMEOUT_MS: "0",
},
);
expect(exitCode).toBe(1);
expect(stderr).toContain("Local emulator publishable client key not found");
} finally {
fs.rmSync(fakeEmulatorHome, { recursive: true });
}
});

it("local-default exec errors when emulator API is unreachable", async ({ expect }) => {
expect(createdProjectId).toBeDefined();
// PCK file present (so we get past the file check) but STACK_EMULATOR_API_URL
// points at a port nothing is listening on — fetch fails with a clear error.
// STACK_EMULATOR_READY_TIMEOUT_MS=0 keeps the retry loop from waiting.
const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-fake-emulator-"));
try {
const pckDir = path.join(fakeEmulatorHome, "run", "vm");
fs.mkdirSync(pckDir, { recursive: true });
fs.writeFileSync(path.join(pckDir, "internal-pck"), "pck_stub_for_test");
const { stderr, exitCode } = await runCli(
["exec", "return 1"],
{
STACK_PROJECT_ID: createdProjectId,
STACK_EMULATOR_HOME: fakeEmulatorHome,
STACK_EMULATOR_API_URL: "http://127.0.0.1:1",
STACK_EMULATOR_READY_TIMEOUT_MS: "0",
},
);
expect(exitCode).toBe(1);
expect(stderr).toContain("Cannot reach local emulator");
} finally {
fs.rmSync(fakeEmulatorHome, { recursive: true });
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Positive happy-path: only runs when the backend is in local-emulator mode
// (the password sign-in for local-emulator@stack-auth.com only succeeds
// there). Stages a STACK_EMULATOR_HOME with the real internal PCK and
// points STACK_EMULATOR_API_URL at the running backend, so the CLI takes
// the local-default path and signs in as the emulator admin.
//
// The CLI signs in as the emulator admin, whose listOwnedProjects() only
// returns projects owned by LOCAL_EMULATOR_OWNER_TEAM_ID. createdProjectId
// is owned by the test user's team and would be invisible, so we mint a
// fresh project via the local-emulator endpoint instead.
it.runIf(isLocalEmulator)("local-default exec runs against the local emulator backend", async ({ expect }) => {
const emulatorConfigPath = path.join(tmpDir, `stack-emulator-${crypto.randomUUID()}.config.ts`);
fs.writeFileSync(emulatorConfigPath, "");
const projectRes = await niceFetch(`${STACK_BACKEND_BASE_URL}/api/v1/internal/local-emulator/project`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-stack-access-type": "server",
"x-stack-project-id": "internal",
"x-stack-publishable-client-key": STACK_INTERNAL_PROJECT_CLIENT_KEY,
"x-stack-secret-server-key": STACK_INTERNAL_PROJECT_SERVER_KEY,
},
body: JSON.stringify({ absolute_file_path: emulatorConfigPath }),
});
if (projectRes.status !== 200) {
throw new Error(`Failed to mint local emulator project: ${projectRes.status} ${JSON.stringify(projectRes.body)}`);
}
const emulatorProjectId = (projectRes.body as { project_id: string }).project_id;

const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-emu-positive-"));
try {
const pckDir = path.join(fakeEmulatorHome, "run", "vm");
fs.mkdirSync(pckDir, { recursive: true });
fs.writeFileSync(path.join(pckDir, "internal-pck"), STACK_INTERNAL_PROJECT_CLIENT_KEY);
const { stdout, stderr, exitCode } = await runCli(
["exec", "return 1+1"],
{
STACK_PROJECT_ID: emulatorProjectId,
STACK_EMULATOR_HOME: fakeEmulatorHome,
STACK_EMULATOR_API_URL: STACK_BACKEND_BASE_URL,
},
);
if (exitCode !== 0) {
throw new Error(`CLI exited ${exitCode}. stderr: ${stderr}`);
}
expect(exitCode).toBe(0);
expect(stdout.trim()).toBe("2");
} finally {
fs.rmSync(fakeEmulatorHome, { recursive: true });
}
});

let configTsPath: string;

it("config pull writes a .ts file", async ({ expect }) => {
Expand Down
48 changes: 47 additions & 1 deletion packages/stack-cli/src/commands/emulator.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { emulatorBackendPort, emulatorDashboardPort, envPort } from "../lib/emulator-paths.js";
import {
envPort,
formatBytes,
formatDuration,
platformInstallHint,
Expand Down Expand Up @@ -128,6 +128,52 @@ describe("envPort", () => {
});
});

describe("emulator port resolution (STACK_ prefix + legacy alias)", () => {
const PORT_VARS = [
"STACK_EMULATOR_BACKEND_PORT",
"EMULATOR_BACKEND_PORT",
"STACK_EMULATOR_DASHBOARD_PORT",
"EMULATOR_DASHBOARD_PORT",
] as const;
const SAVED: Record<string, string | undefined> = {};
beforeEach(() => {
for (const v of PORT_VARS) {
SAVED[v] = process.env[v];
delete process.env[v];
}
});
afterEach(() => {
for (const v of PORT_VARS) {
if (SAVED[v] === undefined) delete process.env[v];
else process.env[v] = SAVED[v];
}
});

it("uses default ports when neither alias is set", () => {
expect(emulatorBackendPort()).toBe(26701);
expect(emulatorDashboardPort()).toBe(26700);
});

it("prefers STACK_ prefix over the unprefixed legacy alias", () => {
process.env.STACK_EMULATOR_BACKEND_PORT = "30001";
process.env.EMULATOR_BACKEND_PORT = "40001";
expect(emulatorBackendPort()).toBe(30001);
});

it("falls back to the unprefixed legacy alias when STACK_ prefix is unset", () => {
process.env.EMULATOR_BACKEND_PORT = "40002";
expect(emulatorBackendPort()).toBe(40002);
});

it("validates the alias that is actually used", () => {
process.env.STACK_EMULATOR_BACKEND_PORT = "not-a-number";
expect(() => emulatorBackendPort()).toThrow(/Invalid STACK_EMULATOR_BACKEND_PORT/);
delete process.env.STACK_EMULATOR_BACKEND_PORT;
process.env.EMULATOR_BACKEND_PORT = "not-a-number";
expect(() => emulatorBackendPort()).toThrow(/Invalid EMULATOR_BACKEND_PORT/);
});
});

describe("resolveArch", () => {
it("accepts explicit arm64 / amd64", () => {
expect(resolveArch("arm64")).toBe("arm64");
Expand Down
Loading
Loading