From acc646cb0bb5c231dd8d3a364e6b1ef10874beef Mon Sep 17 00:00:00 2001
From: aadesh18 <110230993+aadesh18@users.noreply.github.com>
Date: Fri, 8 May 2026 10:47:49 -0700
Subject: [PATCH] stack-cli: cloud/local init flow, auto-create on empty
projects, post-setup next-steps (#1383)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
### Summary
Reworks `stack init` UX, adds Sentry error reporting to the CLI,
polishes the emulator start flow, and overhauls the local-emulator
dashboard's "Open config file" dialog.
#### `stack init` flow
- **New top-level flow.** Drops the old "link existing vs. create new
local" fork. `init` now asks *where* to create the project — "Stack Auth
Cloud" or "Local". Adds a new `create-cloud` mode that logs the user in,
creates a cloud project, mints keys, and writes `.env` — no round-trip
through the dashboard.
- **Conditional emulator-install warning.** The "Local" choice label
only shows "(requires local emulator installation, ~1.3gb storage
required)" when the QEMU image isn't already on disk; otherwise it shows
"(emulator already installed)". Driven by a new
`isEmulatorImageInstalled()` helper in `commands/emulator.ts`.
- **Auto-create on zero-projects.** When the link-from-cloud path hits
an empty project list, the CLI now prompts *"You don't have any Stack
Auth projects yet. Would you like to create one?"* and, on yes, runs the
same flow as `stack project create`. Skips the pointless "select a
project" prompt when we just created one.
- **MCP-server notice.** Before invoking the coding agent, the CLI
announces that it's also registering the Stack Auth MCP server
(`mcp.stack-auth.com`) so the agent can answer Stack-specific questions
going forward.
- **Local-emulator env header.** When `writeProjectKeysToEnv` runs in
`local` mode it writes a 3-line comment header above the keys explaining
they're emulator-only and only valid while the emulator is running.
- **"What's next" footer.** After setup finishes, prints a short
orientation block: where the sign-up/sign-in routes live
(`/handler/sign-up`, `/handler/sign-in`), how to start the local
emulator (for `create` mode), a dashboard deep link for cloud projects
(respects `STACK_DASHBOARD_URL`), and a docs link.
#### Sentry error reporting (`lib/sentry.ts`, `index.ts`,
`tsdown.config.ts`)
- New `lib/sentry.ts` initializes `@sentry/node` with PII scrubbing
(Stack key prefixes, JWTs, home-dir paths, sensitive field names like
`token`/`secret`/`password`/`dsn`).
- DSN is baked at build time via a tsdown `define` sentinel
(`__STACK_CLI_SENTRY_DSN__`) — no DSN in source, no runtime env-var
dependency for installed users. CI sets `STACK_CLI_SENTRY_DSN_BUILD`
before `pnpm build`.
- Disabled when `NODE_ENV=development` or `CI`. No user opt-out.
- Wired into `main()`'s catch (only for unexpected errors —
`CliError`/`AuthError` still print and exit cleanly) plus
`uncaughtException` and `unhandledRejection` handlers via a
`handleFatal` helper.
#### `stack emulator start` welcome
- After a fresh start (not when reusing a running VM, not when
`--config-file` keeps stdout JSON-only), prints a short "Emulator is up"
block with service URLs (dashboard / backend / inbucket) and common
commands (`status`, `stop`, `reset`, `run`).
#### Local-emulator dashboard "Open config file" dialog
The dialog at `http://localhost:26700` (when no project is loaded) used
to be a single text input asking for an absolute path, with no
explanation of where that path comes from.
**Backend**
(`apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx`):
- POST is now tolerant of directory paths or paths that don't end in
`.ts`/`.js`/`.mjs` — it appends `stack.config.ts` and creates the file
if missing (`writeConfigToFile` mkdir's parents). Lets users paste a
project folder instead of hunting for the config file.
- New GET endpoint returns up to 20 most-recent `LocalEmulatorProject`
rows joined with their display names, sorted by `updatedAt` desc. Same
`isLocalEmulatorEnabled()` + client-auth gating as POST.
**Dashboard**
(`apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx`):
- Title changed to "Open your Stack Auth project". Description now
explicitly ties the file to `stack init`: *"Point the local dashboard at
the `stack.config.ts` in your project. If you just ran `stack init`, it
was created at the root of that project."*
- Added: *"Don't have one yet? Paste your project folder path instead
and we'll create stack.config.ts for you."*
- Recent-projects list (clickable rows that prefill the input) fetched
from the new GET endpoint when the dialog opens.
- OS-specific copy-path tip below the input (macOS ⌥-Copy as Pathname,
Windows Shift+RC Copy as path, Linux `realpath`).
- "Open project" button is disabled when the input is empty.
- All error paths (empty input, non-absolute path, server errors,
exceptions) surface via destructive toasts instead of throwing.
Why no native file picker: browsers do not expose absolute filesystem
paths from ``, drag-and-drop, or the File System
Access API. The backend requires an absolute path, so a Finder-style
picker isn't possible from a web page. The recent list + OS tips are the
workaround.
### Goal
The previous `init` flow dead-ended new users: if you had no project you
got an error telling you to go create one in the dashboard and come
back. The happy path also forced a choice between "link existing" and
"create local emulator" — not the question most users are trying to
answer. The emulator dashboard's open-project dialog had similar
friction: an unexplained path field with no recall of previously-opened
projects. And the CLI silently swallowed unexpected errors with no
telemetry. This branch makes the first-run path work end-to-end from the
terminal, gives the emulator dashboard a usable open-project surface,
and turns CLI crashes into actionable bug reports.
### How to review
- Start with `packages/stack-cli/src/commands/init.ts` — the whole
user-facing flow lives in `runInit`. Mode dispatch at the top,
`handleCreateCloud` is the new cloud branch, `printNextSteps` is the
footer, the MCP notice prints right before `runClaudeAgent`.
- `packages/stack-cli/src/lib/sentry.ts` is small and self-contained;
the sentinel-replacement contract is in `tsdown.config.ts`'s `define`
block. Confirm `dist/index.js` contains zero `__STACK_CLI_SENTRY_DSN__`
occurrences after a build with the env var unset, and the actual DSN
host after a build with it set.
- `packages/stack-cli/src/commands/emulator.ts` —
`printEmulatorWelcome()` is the welcome block;
`isEmulatorImageInstalled()` is the new exported helper used by
`init.ts`.
-
`apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx`
— the directory-tolerance branch is in the POST handler around the
`looksLikeConfigFile` check; the GET handler is appended at the bottom.
-
`apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx`
— dialog markup, recent-list fetch effect, `pathCopyTip` memo, and the
toast-based error handling in `handleOpenConfigFile`.
- Non-interactive (CI) paths stay strict: empty-project list still
errors with a pointer to `stack project create --display-name`. No
surprise project creation in CI.
- No tests. The CLI has no harness for the interactive flow;
verification is manual.
## Summary by CodeRabbit
* **New Features**
* Recent local emulator projects listed in the config dialog for quick
selection.
* New CLI create-cloud mode and --display-name flag; interactive cloud
project creation and clearer next steps.
* Emulator start shows a welcome banner with service URLs when a new
instance starts.
* **Improvements**
* Config dialog UX, validation, error-toasting, and platform-aware copy
refined; “Open project” disabled for empty/invalid paths.
* CLI: centralized interactive project creation and improved fatal error
handling.
* **Chores**
* Sentry added and initialized for CLI error reporting.
---------
Co-authored-by: Bilal Godil
---
.../internal/local-emulator/project/route.tsx | 110 ++++++-
.../projects/page-client.tsx | 134 ++++++++-
packages/stack-cli/package.json | 1 +
packages/stack-cli/src/commands/emulator.ts | 39 +++
packages/stack-cli/src/commands/init.ts | 146 +++++----
packages/stack-cli/src/commands/project.ts | 4 +-
packages/stack-cli/src/index.ts | 10 +-
packages/stack-cli/src/lib/create-project.ts | 13 +-
packages/stack-cli/src/lib/sentry.ts | 96 ++++++
packages/stack-cli/tsdown.config.ts | 3 +
pnpm-lock.yaml | 280 +++++++++++++-----
11 files changed, 665 insertions(+), 171 deletions(-)
create mode 100644 packages/stack-cli/src/lib/sentry.ts
diff --git a/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx b/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx
index fd6ccf514..77b2afa71 100644
--- a/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx
+++ b/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx
@@ -4,10 +4,11 @@ import {
LOCAL_EMULATOR_ADMIN_USER_ID,
LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE,
LOCAL_EMULATOR_OWNER_TEAM_ID,
- isLocalEmulatorOnboardingEnabledInConfig,
isLocalEmulatorEnabled,
+ isLocalEmulatorOnboardingEnabledInConfig,
readConfigFromFile,
resolveEmulatorPath,
+ writeConfigToFile,
writeShowOnboardingConfigToFile,
} from "@/lib/local-emulator";
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
@@ -18,6 +19,7 @@ import {
projectOnboardingStatusSchema,
projectOnboardingStatusValues,
type ProjectOnboardingStatus,
+ yupArray,
yupBoolean,
yupNumber,
yupObject,
@@ -37,6 +39,14 @@ function isProjectOnboardingStatus(value: string): value is ProjectOnboardingSta
return projectOnboardingStatusValues.some((status) => status === value);
}
+function deriveDisplayLabel(absoluteFilePath: string): string {
+ const base = path.basename(absoluteFilePath);
+ if (base.toLowerCase() === "stack.config.ts") {
+ return path.basename(path.dirname(absoluteFilePath)) || base;
+ }
+ return base;
+}
+
async function assertLocalEmulatorOwnerTeamReadiness() {
const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID);
const internalPrisma = await getPrismaClientForTenancy(internalTenancy);
@@ -90,7 +100,7 @@ async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Prom
update: {},
create: {
id: projectId,
- displayName: `Local Emulator: ${path.basename(absoluteFilePath) || "Project"}`,
+ displayName: `Local Emulator: ${deriveDisplayLabel(absoluteFilePath) || "Project"}`,
description: `Local emulator project for ${absoluteFilePath}`,
isProductionMode: false,
ownerTeamId: LOCAL_EMULATOR_OWNER_TEAM_ID,
@@ -287,14 +297,30 @@ export const POST = createSmartRouteHandler({
if (!isLocalEmulatorEnabled()) {
throw new StatusError(StatusError.BadRequest, LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE);
}
- if (!path.isAbsolute(req.body.absolute_file_path)) {
- throw new StatusError(StatusError.BadRequest, "absolute_file_path must be an absolute path.");
+ if (!path.posix.isAbsolute(req.body.absolute_file_path)) {
+ const looksWindows = path.win32.isAbsolute(req.body.absolute_file_path);
+ throw new StatusError(
+ StatusError.BadRequest,
+ looksWindows
+ ? "absolute_file_path must be a POSIX absolute path. The local emulator runs in a Linux VM and does not accept Windows-style paths. Use the in-VM path or run the emulator from WSL."
+ : "absolute_file_path must be an absolute path.",
+ );
}
- const absoluteFilePath = path.resolve(req.body.absolute_file_path);
- const resolvedFilePath = resolveEmulatorPath(absoluteFilePath);
+ const inputPath = path.resolve(req.body.absolute_file_path);
+ let inputStat;
+ try {
+ inputStat = await fs.stat(resolveEmulatorPath(inputPath));
+ } catch {
+ inputStat = undefined;
+ }
- // Validate file exists before creating a project
+ const looksLikeConfigFile = /\.(ts|js|mjs)$/i.test(inputPath);
+ const absoluteFilePath = (inputStat?.isDirectory() || (!inputStat && !looksLikeConfigFile))
+ ? path.join(inputPath, "stack.config.ts")
+ : inputPath;
+
+ const resolvedFilePath = resolveEmulatorPath(absoluteFilePath);
let fileExists: boolean;
try {
await fs.access(resolvedFilePath);
@@ -303,7 +329,7 @@ export const POST = createSmartRouteHandler({
fileExists = false;
}
if (!fileExists) {
- throw new StatusError(StatusError.BadRequest, `Config file not found: ${absoluteFilePath}`);
+ await writeConfigToFile(absoluteFilePath, {});
}
const fileContent = await fs.readFile(resolvedFilePath, "utf-8");
@@ -335,3 +361,71 @@ export const POST = createSmartRouteHandler({
};
},
});
+
+type LocalEmulatorProjectListRow = {
+ projectId: string,
+ absoluteFilePath: string,
+ updatedAt: Date,
+};
+
+export const GET = createSmartRouteHandler({
+ metadata: {
+ hidden: true,
+ summary: "List recent local emulator projects",
+ description: "Returns previously opened local emulator project mappings, most-recent first.",
+ tags: ["Local Emulator"],
+ },
+ request: yupObject({
+ auth: yupObject({
+ type: clientOrHigherAuthTypeSchema.defined(),
+ project: yupObject({
+ id: yupString().oneOf(["internal"]).defined(),
+ }).defined(),
+ }).defined(),
+ method: yupString().oneOf(["GET"]).defined(),
+ }),
+ response: yupObject({
+ statusCode: yupNumber().oneOf([200]).defined(),
+ bodyType: yupString().oneOf(["json"]).defined(),
+ body: yupObject({
+ projects: yupArray(yupObject({
+ project_id: yupString().defined(),
+ absolute_file_path: yupString().defined(),
+ display_name: yupString().defined(),
+ }).defined()).defined(),
+ }).defined(),
+ }),
+ handler: async () => {
+ if (!isLocalEmulatorEnabled()) {
+ throw new StatusError(StatusError.BadRequest, LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE);
+ }
+
+ const rows = await globalPrismaClient.$queryRaw(Prisma.sql`
+ SELECT "projectId", "absoluteFilePath", "updatedAt"
+ FROM "LocalEmulatorProject"
+ ORDER BY "updatedAt" DESC
+ LIMIT 20
+ `);
+
+ const projectIds = rows.map((r) => r.projectId);
+ const projects = projectIds.length > 0
+ ? await globalPrismaClient.project.findMany({
+ where: { id: { in: projectIds } },
+ select: { id: true, displayName: true },
+ })
+ : [];
+ const displayNameById = new Map(projects.map((p) => [p.id, p.displayName]));
+
+ return {
+ statusCode: 200 as const,
+ bodyType: "json" as const,
+ body: {
+ projects: rows.map((r) => ({
+ project_id: r.projectId,
+ absolute_file_path: r.absoluteFilePath,
+ display_name: displayNameById.get(r.projectId) ?? deriveDisplayLabel(r.absoluteFilePath),
+ })),
+ },
+ };
+ },
+});
diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx
index 66d36f8e2..062c64298 100644
--- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx
+++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx
@@ -66,6 +66,8 @@ export default function PageClient() {
const [openConfigFileDialog, setOpenConfigFileDialog] = useState(false);
const [absoluteConfigFilePath, setAbsoluteConfigFilePath] = useState("");
const [openingConfigFile, setOpeningConfigFile] = useState(false);
+ const [recentConfigProjects, setRecentConfigProjects] = useState>([]);
+ const [recentConfigProjectsError, setRecentConfigProjectsError] = useState(false);
const [projectStatuses, setProjectStatuses] = useState