Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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