Skip to content

Commit 6acd561

Browse files
committed
stack exec: default to local emulator, add --cloud opt-out
Local-first dev workflow: `stack exec` now signs in as the emulator admin via the well-known shared credentials and the run-dir PCK, falling back to the cloud only when --cloud is passed (or STACK_EXEC_DEFAULT_TARGET=cloud is set). Also: STACK_EMULATOR_*_PORT env vars take precedence over the legacy unprefixed names; emulator paths/ports/PCK polling extracted to lib/emulator-paths.ts; shared local-emulator admin creds hoisted to stack-shared so backend, dashboard auto-login, and CLI agree.
1 parent ff01ca8 commit 6acd561

17 files changed

Lines changed: 635 additions & 107 deletions

File tree

apps/backend/src/lib/local-emulator.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { globalPrismaClient } from "@/prisma-client";
22
import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering";
33
import { isValidConfig } from "@stackframe/stack-shared/dist/config/format";
4+
import { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD } from "@stackframe/stack-shared/dist/local-emulator";
45
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
56
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
67
import fs from "fs/promises";
@@ -9,8 +10,7 @@ import path from "path";
910

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

1515
export const LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE =
1616
"Environment configuration overrides cannot be changed in the local emulator. Update this in your production deployment instead.";

apps/dashboard/src/app/(main)/(protected)/layout-client.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { CursorBlastEffect } from "@stackframe/dashboard-ui-components";
55
import { ConfigUpdateDialogProvider } from "@/lib/config-update";
66
import { getPublicEnvVar } from '@/lib/env';
77
import { useStackApp, useUser } from "@stackframe/stack";
8+
import { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD } from "@stackframe/stack-shared/dist/local-emulator";
89
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
910
import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
1011
import { useEffect } from "react";
@@ -20,8 +21,8 @@ export default function LayoutClient({ children }: { children: React.ReactNode }
2021
if (user) return;
2122
if (isLocalEmulator) {
2223
await app.signInWithCredential({
23-
email: "local-emulator@stack-auth.com",
24-
password: "LocalEmulatorPassword",
24+
email: LOCAL_EMULATOR_ADMIN_EMAIL,
25+
password: LOCAL_EMULATOR_ADMIN_PASSWORD,
2526
});
2627
} else if (isPreview) {
2728
const id = generateUuid();

apps/e2e/tests/general/cli.test.ts

Lines changed: 99 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { Result } from "@stackframe/stack-shared/dist/utils/results";
77
import { describe, beforeAll, afterAll } from "vitest";
88
import { it, niceFetch, STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_SERVER_KEY, STACK_INTERNAL_PROJECT_ADMIN_KEY } from "../helpers";
99

10+
const isLocalEmulator = process.env.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR === "true";
11+
1012
const CLI_BIN = path.resolve("packages/stack-cli/dist/index.js");
1113
const CLI_SRC_BIN = path.resolve("packages/stack-cli/src/index.ts");
1214

@@ -134,6 +136,9 @@ describe("Stack CLI", () => {
134136
});
135137

136138
it("errors when no project ID given", async ({ expect }) => {
139+
// Exercise the default (local) path: project-ID resolution happens before
140+
// any emulator I/O, so the missing-ID error fires regardless of whether
141+
// an emulator is running.
137142
const { stderr, exitCode } = await runCli(["exec", "return 1"]);
138143
expect(exitCode).toBe(1);
139144
expect(stderr).toContain("No project ID");
@@ -183,7 +188,7 @@ describe("Stack CLI", () => {
183188
it("returns basic expression", async ({ expect }) => {
184189
expect(createdProjectId).toBeDefined();
185190
const { stdout, exitCode } = await runCli(
186-
["exec", "return 1+1"],
191+
["exec", "--cloud", "return 1+1"],
187192
{ STACK_PROJECT_ID: createdProjectId },
188193
);
189194
expect(exitCode).toBe(0);
@@ -192,7 +197,7 @@ describe("Stack CLI", () => {
192197

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

213+
it("exec help mentions --cloud option", async ({ expect }) => {
214+
const { stdout, exitCode } = await runCli(["exec", "--help"]);
215+
expect(exitCode).toBe(0);
216+
expect(stdout).toContain("--cloud");
217+
});
218+
208219
it("errors when no javascript is provided", async ({ expect }) => {
209-
const { stderr, exitCode } = await runCli(["exec"], { STACK_PROJECT_ID: createdProjectId });
220+
const { stderr, exitCode } = await runCli(["exec", "--cloud"], { STACK_PROJECT_ID: createdProjectId });
210221
expect(exitCode).toBe(1);
211222
expect(stderr).toContain("Missing JavaScript argument");
212223
});
213224

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

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

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

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

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

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

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

278289
it("supports async code", async ({ expect }) => {
279290
const { stdout, exitCode } = await runCli(
280-
["exec", "return await Promise.resolve(42)"],
291+
["exec", "--cloud", "return await Promise.resolve(42)"],
281292
{ STACK_PROJECT_ID: createdProjectId },
282293
);
283294
expect(exitCode).toBe(0);
@@ -290,7 +301,7 @@ describe("Stack CLI", () => {
290301
createdUserEmail = `exec-test-${crypto.randomUUID()}@stack-generated.example.com`;
291302
const code = `const u = await stackServerApp.createUser({ primaryEmail: "${createdUserEmail}", password: "test123456" }); return { id: u.id, email: u.primaryEmail }`;
292303
const { stdout, exitCode } = await runCli(
293-
["exec", code],
304+
["exec", "--cloud", code],
294305
{ STACK_PROJECT_ID: createdProjectId },
295306
);
296307
expect(exitCode).toBe(0);
@@ -303,14 +314,89 @@ describe("Stack CLI", () => {
303314
expect(createdProjectId).toBeDefined();
304315
expect(createdUserEmail).toBeDefined();
305316
const { stdout, exitCode } = await runCli(
306-
["exec", "const users = await stackServerApp.listUsers(); return users.length"],
317+
["exec", "--cloud", "const users = await stackServerApp.listUsers(); return users.length"],
307318
{ STACK_PROJECT_ID: createdProjectId },
308319
);
309320
expect(exitCode).toBe(0);
310321
const count = JSON.parse(stdout);
311322
expect(count).toBeGreaterThanOrEqual(1);
312323
});
313324

325+
it("local-default exec errors when emulator PCK file is missing", async ({ expect }) => {
326+
// Without --cloud, exec defaults to the local emulator. With
327+
// STACK_EMULATOR_HOME pointed at an empty dir, the PCK file lookup fires
328+
// before any network call and we get a clear error. Setting
329+
// STACK_EMULATOR_READY_TIMEOUT_MS=0 disables the boot-race polling window
330+
// so this test fails fast.
331+
const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-fake-emulator-"));
332+
try {
333+
const { stderr, exitCode } = await runCli(
334+
["exec", "return 1"],
335+
{
336+
STACK_PROJECT_ID: createdProjectId,
337+
STACK_EMULATOR_HOME: fakeEmulatorHome,
338+
STACK_EMULATOR_READY_TIMEOUT_MS: "0",
339+
},
340+
);
341+
expect(exitCode).toBe(1);
342+
expect(stderr).toContain("Local emulator publishable client key not found");
343+
} finally {
344+
fs.rmSync(fakeEmulatorHome, { recursive: true });
345+
}
346+
});
347+
348+
it("local-default exec errors when emulator API is unreachable", async ({ expect }) => {
349+
// PCK file present (so we get past the file check) but STACK_EMULATOR_API_URL
350+
// points at a port nothing is listening on — fetch fails with a clear error.
351+
// STACK_EMULATOR_READY_TIMEOUT_MS=0 keeps the retry loop from waiting.
352+
const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-fake-emulator-"));
353+
try {
354+
const pckDir = path.join(fakeEmulatorHome, "run", "vm");
355+
fs.mkdirSync(pckDir, { recursive: true });
356+
fs.writeFileSync(path.join(pckDir, "internal-pck"), "pck_stub_for_test");
357+
const { stderr, exitCode } = await runCli(
358+
["exec", "return 1"],
359+
{
360+
STACK_PROJECT_ID: createdProjectId,
361+
STACK_EMULATOR_HOME: fakeEmulatorHome,
362+
STACK_EMULATOR_API_URL: "http://127.0.0.1:1",
363+
STACK_EMULATOR_READY_TIMEOUT_MS: "0",
364+
},
365+
);
366+
expect(exitCode).toBe(1);
367+
expect(stderr).toContain("Cannot reach local emulator");
368+
} finally {
369+
fs.rmSync(fakeEmulatorHome, { recursive: true });
370+
}
371+
});
372+
373+
// Positive happy-path: only runs when the backend is in local-emulator mode
374+
// (the password sign-in for local-emulator@stack-auth.com only succeeds
375+
// there). Stages a STACK_EMULATOR_HOME with the real internal PCK and
376+
// points STACK_EMULATOR_API_URL at the running backend, so the CLI takes
377+
// the local-default path and signs in as the emulator admin.
378+
it.runIf(isLocalEmulator)("local-default exec runs against the local emulator backend", async ({ expect }) => {
379+
expect(createdProjectId).toBeDefined();
380+
const fakeEmulatorHome = fs.mkdtempSync(path.join(os.tmpdir(), "stack-cli-emu-positive-"));
381+
try {
382+
const pckDir = path.join(fakeEmulatorHome, "run", "vm");
383+
fs.mkdirSync(pckDir, { recursive: true });
384+
fs.writeFileSync(path.join(pckDir, "internal-pck"), STACK_INTERNAL_PROJECT_CLIENT_KEY);
385+
const { stdout, exitCode } = await runCli(
386+
["exec", "return 1+1"],
387+
{
388+
STACK_PROJECT_ID: createdProjectId,
389+
STACK_EMULATOR_HOME: fakeEmulatorHome,
390+
STACK_EMULATOR_API_URL: STACK_BACKEND_BASE_URL,
391+
},
392+
);
393+
expect(exitCode).toBe(0);
394+
expect(stdout.trim()).toBe("2");
395+
} finally {
396+
fs.rmSync(fakeEmulatorHome, { recursive: true });
397+
}
398+
});
399+
314400
let configTsPath: string;
315401

316402
it("config pull writes a .ts file", async ({ expect }) => {

packages/stack-cli/src/commands/emulator.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { emulatorBackendPort, emulatorDashboardPort, envPort } from "../lib/emulator-paths.js";
23
import {
3-
envPort,
44
formatBytes,
55
formatDuration,
66
platformInstallHint,
@@ -128,6 +128,52 @@ describe("envPort", () => {
128128
});
129129
});
130130

131+
describe("emulator port resolution (STACK_ prefix + legacy alias)", () => {
132+
const PORT_VARS = [
133+
"STACK_EMULATOR_BACKEND_PORT",
134+
"EMULATOR_BACKEND_PORT",
135+
"STACK_EMULATOR_DASHBOARD_PORT",
136+
"EMULATOR_DASHBOARD_PORT",
137+
] as const;
138+
const SAVED: Record<string, string | undefined> = {};
139+
beforeEach(() => {
140+
for (const v of PORT_VARS) {
141+
SAVED[v] = process.env[v];
142+
delete process.env[v];
143+
}
144+
});
145+
afterEach(() => {
146+
for (const v of PORT_VARS) {
147+
if (SAVED[v] === undefined) delete process.env[v];
148+
else process.env[v] = SAVED[v];
149+
}
150+
});
151+
152+
it("uses default ports when neither alias is set", () => {
153+
expect(emulatorBackendPort()).toBe(26701);
154+
expect(emulatorDashboardPort()).toBe(26700);
155+
});
156+
157+
it("prefers STACK_ prefix over the unprefixed legacy alias", () => {
158+
process.env.STACK_EMULATOR_BACKEND_PORT = "30001";
159+
process.env.EMULATOR_BACKEND_PORT = "40001";
160+
expect(emulatorBackendPort()).toBe(30001);
161+
});
162+
163+
it("falls back to the unprefixed legacy alias when STACK_ prefix is unset", () => {
164+
process.env.EMULATOR_BACKEND_PORT = "40002";
165+
expect(emulatorBackendPort()).toBe(40002);
166+
});
167+
168+
it("validates the alias that is actually used", () => {
169+
process.env.STACK_EMULATOR_BACKEND_PORT = "not-a-number";
170+
expect(() => emulatorBackendPort()).toThrow(/Invalid STACK_EMULATOR_BACKEND_PORT/);
171+
delete process.env.STACK_EMULATOR_BACKEND_PORT;
172+
process.env.EMULATOR_BACKEND_PORT = "not-a-number";
173+
expect(() => emulatorBackendPort()).toThrow(/Invalid EMULATOR_BACKEND_PORT/);
174+
});
175+
});
176+
131177
describe("resolveArch", () => {
132178
it("accepts explicit arm64 / amd64", () => {
133179
expect(resolveArch("arm64")).toBe("arm64");

0 commit comments

Comments
 (0)