From 233dd50d4f4f8b854115e4f1348466f534fa953a Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 23 Apr 2026 15:39:52 -0700 Subject: [PATCH 1/4] cli init changes --- packages/stack-cli/src/commands/init.ts | 32 +++++++++++-------------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/packages/stack-cli/src/commands/init.ts b/packages/stack-cli/src/commands/init.ts index 8dc3ee1076..1bfe6643fe 100644 --- a/packages/stack-cli/src/commands/init.ts +++ b/packages/stack-cli/src/commands/init.ts @@ -57,15 +57,13 @@ async function runInit(program: Command, opts: InitOptions) { console.log("Welcome to Stack Auth!\n"); - const mode: string = "link"; - // TODO: re-enable local emulator option - // const mode: string = opts.mode ?? await select({ - // message: "Would you like to link to an existing project, or create a new one?", - // choices: [ - // { name: "Create a new project (local emulator)", value: "create" as const }, - // { name: "Link an existing project", value: "link" as const }, - // ], - // }); + const mode: string = opts.mode ?? await select({ + message: "Would you like to link to an existing project, or create a new one?", + choices: [ + { name: "Link an existing project", value: "link" as const }, + { name: "Create a new project (local emulator)", value: "create" as const }, + ], + }); let configPath: string | undefined; @@ -104,15 +102,13 @@ async function handleLink(flags: Record, opts: InitOptions, out } else if (opts.mode === "link-cloud") { source = "cloud"; } else { - source = "cloud"; - // TODO: re-enable config file linking option - // source = await select({ - // message: "How would you like to link your project?", - // choices: [ - // { name: "Link from config file", value: "config-file" as const }, - // { name: "Link from app.stack-auth.com", value: "cloud" as const }, - // ], - // }); + source = await select({ + message: "How would you like to link your project?", + choices: [ + { name: "Link from config file", value: "config-file" as const }, + { name: "Link from app.stack-auth.com", value: "cloud" as const }, + ], + }); } if (source === "config-file") { From 5586a3639af75796bca04adf57e24582b447435a Mon Sep 17 00:00:00 2001 From: aadesh18 <110230993+aadesh18@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:29:55 -0700 Subject: [PATCH 2/4] initial commit (#1382) Adds the ability to create a project using cli. --- packages/stack-cli/src/commands/init.ts | 138 +++++++++++++------ packages/stack-cli/src/commands/project.ts | 38 +---- packages/stack-cli/src/lib/create-project.ts | 36 +++++ 3 files changed, 136 insertions(+), 76 deletions(-) create mode 100644 packages/stack-cli/src/lib/create-project.ts diff --git a/packages/stack-cli/src/commands/init.ts b/packages/stack-cli/src/commands/init.ts index 1bfe6643fe..50c26372a8 100644 --- a/packages/stack-cli/src/commands/init.ts +++ b/packages/stack-cli/src/commands/init.ts @@ -10,11 +10,12 @@ import { writeConfigValue } from "../lib/config.js"; import { CliError, AuthError } from "../lib/errors.js"; import { isNonInteractiveEnv } from "../lib/interactive.js"; import { createInitPrompt } from "../lib/init-prompt.js"; +import { createProjectInteractively } from "../lib/create-project.js"; import { runClaudeAgent } from "../lib/claude-agent.js"; import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering"; type InitOptions = { - mode?: "create" | "link-config" | "link-cloud", + mode?: "create" | "create-cloud" | "link-config" | "link-cloud", apps?: string, configFile?: string, selectProjectId?: string, @@ -26,7 +27,7 @@ export function registerInitCommand(program: Command) { program .command("init") .description("Initialize Stack Auth in your project") - .option("--mode ", "Mode: create, link-config, or link-cloud (skips interactive prompts)") + .option("--mode ", "Mode: create, create-cloud, link-config, or link-cloud (skips interactive prompts)") .option("--apps ", "Comma-separated app IDs to enable (for create mode)") .option("--config-file ", "Path to existing config file (for link-config mode)") .option("--select-project-id ", "Project ID to link (for link-cloud mode)") @@ -57,13 +58,24 @@ async function runInit(program: Command, opts: InitOptions) { console.log("Welcome to Stack Auth!\n"); - const mode: string = opts.mode ?? await select({ - message: "Would you like to link to an existing project, or create a new one?", - choices: [ - { name: "Link an existing project", value: "link" as const }, - { name: "Create a new project (local emulator)", value: "create" as const }, - ], - }); + let mode: string; + if (opts.mode) { + mode = opts.mode; + } else if (opts.selectProjectId) { + mode = "link-cloud"; + } else if (opts.configFile) { + mode = "link-config"; + } else { + console.log("Creating a new Stack Auth project.\n"); + const location = await select({ + message: "Where would you like to create the project?", + choices: [ + { name: "Stack Auth Cloud", value: "hosted" as const }, + { name: "Local (requires local emulator installation, ~1.3gb storage required)", value: "local" as const }, + ], + }); + mode = location === "local" ? "create" : "create-cloud"; + } let configPath: string | undefined; @@ -73,6 +85,9 @@ async function runInit(program: Command, opts: InitOptions) { } else if (mode === "create") { const result = await handleCreate(opts, outputDir); configPath = result.configPath; + } else if (mode === "create-cloud") { + const result = await handleCreateCloud(flags, opts, outputDir); + configPath = result.configPath; } else { throw new CliError(`Unknown mode: ${mode}`); } @@ -138,10 +153,9 @@ async function handleLinkFromConfigFile(opts: InitOptions): Promise<{ configPath return { configPath }; } -async function handleLinkFromCloud(flags: Record, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> { - let sessionAuth; +async function ensureLoggedInSession(flags: Record) { try { - sessionAuth = resolveSessionAuth(flags as { projectId?: string }); + return resolveSessionAuth(flags as { projectId?: string }); } catch (e) { if (e instanceof AuthError) { if (isNonInteractiveEnv()) { @@ -149,37 +163,16 @@ async function handleLinkFromCloud(flags: Record, opts: InitOpt } console.log("You need to log in first.\n"); await performLogin(flags); - sessionAuth = resolveSessionAuth(flags as { projectId?: string }); - } else { - throw e; + return resolveSessionAuth(flags as { projectId?: string }); } + throw e; } +} - const user = await getInternalUser(sessionAuth); - const projects = await user.listOwnedProjects(); - - if (projects.length === 0) { - throw new CliError("You don't own any projects. Create one at app.stack-auth.com first."); - } - - let projectId: string; - if (opts.selectProjectId) { - const found = projects.find((p) => p.id === opts.selectProjectId); - if (!found) { - throw new CliError(`Project '${opts.selectProjectId}' not found among your owned projects.`); - } - projectId = opts.selectProjectId; - } else { - projectId = await select({ - message: "Select a project:", - choices: projects.map((p) => ({ - name: `${p.displayName} (${p.id})`, - value: p.id, - })), - }); - } - - const project = projects.find((p) => p.id === projectId)!; +async function writeProjectKeysToEnv( + project: { id: string, app: { createInternalApiKey: (opts: { description: string, expiresAt: Date, hasPublishableClientKey: boolean, hasSecretServerKey: boolean, hasSuperSecretAdminKey: boolean }) => Promise<{ publishableClientKey?: string | null, secretServerKey?: string | null }> } }, + outputDir: string, +) { const apiKey = await project.app.createInternalApiKey({ description: "Created by CLI init script", expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 200), // 200 years @@ -190,7 +183,7 @@ async function handleLinkFromCloud(flags: Record, opts: InitOpt const envLines = [ "# Stack Auth", - `NEXT_PUBLIC_STACK_PROJECT_ID=${projectId}`, + `NEXT_PUBLIC_STACK_PROJECT_ID=${project.id}`, `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${apiKey.publishableClientKey ?? ""}`, `STACK_SECRET_SERVER_KEY=${apiKey.secretServerKey ?? ""}`, ].join("\n"); @@ -222,7 +215,70 @@ async function handleLinkFromCloud(flags: Record, opts: InitOpt fs.writeFileSync(envPath, envLines + "\n"); console.log("\nCreated .env with Stack Auth keys"); } +} + +async function handleCreateCloud(flags: Record, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> { + const sessionAuth = await ensureLoggedInSession(flags); + const user = await getInternalUser(sessionAuth); + const newProject = await createProjectInteractively(user, { + defaultDisplayName: path.basename(outputDir), + }); + console.log(`\nCreated project: ${newProject.displayName} (${newProject.id})\n`); + + await writeProjectKeysToEnv(newProject, outputDir); + return {}; +} + +async function handleLinkFromCloud(flags: Record, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> { + const sessionAuth = await ensureLoggedInSession(flags); + const user = await getInternalUser(sessionAuth); + let projects = await user.listOwnedProjects(); + let autoCreatedProjectId: string | null = null; + + if (projects.length === 0) { + if (isNonInteractiveEnv()) { + throw new CliError("No projects found. Run `stack project create --display-name ` first, or set --select-project-id."); + } + + const shouldCreate = await confirm({ + message: "You don't have any Stack Auth projects yet. Would you like to create one?", + default: true, + }); + + if (!shouldCreate) { + throw new CliError("You don't own any projects. Create one at app.stack-auth.com or re-run and choose to create one."); + } + + const newProject = await createProjectInteractively(user, { + defaultDisplayName: path.basename(outputDir), + }); + console.log(`\nCreated project: ${newProject.displayName} (${newProject.id})\n`); + projects = [newProject]; + autoCreatedProjectId = newProject.id; + } + + let projectId: string; + if (opts.selectProjectId) { + const found = projects.find((p) => p.id === opts.selectProjectId); + if (!found) { + throw new CliError(`Project '${opts.selectProjectId}' not found among your owned projects.`); + } + projectId = opts.selectProjectId; + } else if (autoCreatedProjectId) { + projectId = autoCreatedProjectId; + } else { + projectId = await select({ + message: "Select a project:", + choices: projects.map((p) => ({ + name: `${p.displayName} (${p.id})`, + value: p.id, + })), + }); + } + + const project = projects.find((p) => p.id === projectId)!; + await writeProjectKeysToEnv(project, outputDir); return {}; } diff --git a/packages/stack-cli/src/commands/project.ts b/packages/stack-cli/src/commands/project.ts index 16dbfc4e62..2b1f49ff16 100644 --- a/packages/stack-cli/src/commands/project.ts +++ b/packages/stack-cli/src/commands/project.ts @@ -1,22 +1,7 @@ import { Command } from "commander"; -import * as readline from "readline"; import { resolveSessionAuth } from "../lib/auth.js"; import { getInternalUser } from "../lib/app.js"; -import { isNonInteractiveEnv } from "../lib/interactive.js"; -import { CliError } from "../lib/errors.js"; - -function prompt(question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer); - }); - }); -} +import { createProjectInteractively } from "../lib/create-project.js"; export function registerProjectCommand(program: Command) { const project = program @@ -54,25 +39,8 @@ export function registerProjectCommand(program: Command) { const auth = resolveSessionAuth(flags); const user = await getInternalUser(auth); - let displayName: string = opts.displayName; - if (!displayName) { - if (isNonInteractiveEnv()) { - throw new CliError("--display-name is required in non-interactive environments (CI)."); - } - displayName = await prompt("Project display name: "); - if (!displayName.trim()) { - throw new CliError("Display name cannot be empty."); - } - } - - const teams = await user.listTeams(); - if (teams.length === 0) { - throw new CliError("No teams found. You need a team to create a project."); - } - - const newProject = await user.createProject({ - displayName, - teamId: teams[0].id, + const newProject = await createProjectInteractively(user, { + displayName: opts.displayName, }); if (program.opts().json) { diff --git a/packages/stack-cli/src/lib/create-project.ts b/packages/stack-cli/src/lib/create-project.ts new file mode 100644 index 0000000000..686a2c96ba --- /dev/null +++ b/packages/stack-cli/src/lib/create-project.ts @@ -0,0 +1,36 @@ +import { input } from "@inquirer/prompts"; +import type { CurrentInternalUser } from "@stackframe/js"; +import { CliError } from "./errors.js"; +import { isNonInteractiveEnv } from "./interactive.js"; + +type CreateProjectOptions = { + displayName?: string, + defaultDisplayName?: string, +}; + +export async function createProjectInteractively( + user: CurrentInternalUser, + opts: CreateProjectOptions = {}, +) { + let displayName = opts.displayName; + if (!displayName) { + if (isNonInteractiveEnv()) { + throw new CliError("--display-name is required in non-interactive environments (CI)."); + } + displayName = await input({ + message: "Project display name:", + default: opts.defaultDisplayName, + validate: (v) => v.trim().length > 0 || "Display name cannot be empty.", + }); + } + + const teams = await user.listTeams(); + if (teams.length === 0) { + throw new CliError("No teams found on your account. Create a team at app.stack-auth.com first."); + } + + return await user.createProject({ + displayName: displayName.trim(), + teamId: teams[0].id, + }); +} From ba2d75f80b0b302bfa3910ae9f3e1a7645212601 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 24 Apr 2026 10:52:58 -0700 Subject: [PATCH 3/4] fix(stack-cli): honor inferred link mode and fail loudly on missing API keys - handleLink now respects the mode resolved by runInit (from --config-file / --select-project-id), so those flags skip the interactive source prompt. - writeProjectKeysToEnv throws via throwErr instead of silently writing empty publishable/secret keys to .env when the API returns null. --- packages/stack-cli/src/commands/init.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/stack-cli/src/commands/init.ts b/packages/stack-cli/src/commands/init.ts index 50c26372a8..74987f8a44 100644 --- a/packages/stack-cli/src/commands/init.ts +++ b/packages/stack-cli/src/commands/init.ts @@ -13,6 +13,7 @@ import { createInitPrompt } from "../lib/init-prompt.js"; import { createProjectInteractively } from "../lib/create-project.js"; import { runClaudeAgent } from "../lib/claude-agent.js"; import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; type InitOptions = { mode?: "create" | "create-cloud" | "link-config" | "link-cloud", @@ -80,7 +81,7 @@ async function runInit(program: Command, opts: InitOptions) { let configPath: string | undefined; if (mode === "link" || mode === "link-config" || mode === "link-cloud") { - const result = await handleLink(flags, opts, outputDir); + const result = await handleLink(flags, opts, outputDir, mode); configPath = result.configPath; } else if (mode === "create") { const result = await handleCreate(opts, outputDir); @@ -109,12 +110,12 @@ async function runInit(program: Command, opts: InitOptions) { } } -async function handleLink(flags: Record, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> { +async function handleLink(flags: Record, opts: InitOptions, outputDir: string, resolvedMode?: string): Promise<{ configPath?: string }> { let source: "config-file" | "cloud"; - if (opts.mode === "link-config") { + if (resolvedMode === "link-config" || opts.mode === "link-config") { source = "config-file"; - } else if (opts.mode === "link-cloud") { + } else if (resolvedMode === "link-cloud" || opts.mode === "link-cloud") { source = "cloud"; } else { source = await select({ @@ -181,11 +182,14 @@ async function writeProjectKeysToEnv( hasSuperSecretAdminKey: false, }); + const publishableClientKey = apiKey.publishableClientKey ?? throwErr("createInternalApiKey returned no publishableClientKey despite hasPublishableClientKey=true"); + const secretServerKey = apiKey.secretServerKey ?? throwErr("createInternalApiKey returned no secretServerKey despite hasSecretServerKey=true"); + const envLines = [ "# Stack Auth", `NEXT_PUBLIC_STACK_PROJECT_ID=${project.id}`, - `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${apiKey.publishableClientKey ?? ""}`, - `STACK_SECRET_SERVER_KEY=${apiKey.secretServerKey ?? ""}`, + `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${publishableClientKey}`, + `STACK_SECRET_SERVER_KEY=${secretServerKey}`, ].join("\n"); const envPath = path.resolve(outputDir, ".env"); From ff50c97027be857d5a1f4e77c7d3da4bcc193308 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 24 Apr 2026 17:11:56 -0700 Subject: [PATCH 4/4] fix(stack-cli): address PR #1379 review feedback on init flow - Restore interactive "link existing" option alongside create - Validate incompatible flag combos (e.g. --apps with --mode create-cloud) - Fail upfront if --output-dir does not exist, avoiding orphan projects - Prompt before overwriting an existing stack.config.ts - Tighten mode union and convert dispatch to exhaustive switch --- packages/stack-cli/src/commands/init.ts | 107 +++++++++++++++++++----- 1 file changed, 86 insertions(+), 21 deletions(-) diff --git a/packages/stack-cli/src/commands/init.ts b/packages/stack-cli/src/commands/init.ts index 74987f8a44..9053308566 100644 --- a/packages/stack-cli/src/commands/init.ts +++ b/packages/stack-cli/src/commands/init.ts @@ -53,13 +53,45 @@ export function registerInitCommand(program: Command) { }); } +function validateOptions(opts: InitOptions) { + if (opts.selectProjectId && opts.configFile) { + throw new CliError("--select-project-id and --config-file cannot be used together."); + } + + const incompatible: Record, Array> = { + "create": ["selectProjectId", "configFile"], + "create-cloud": ["selectProjectId", "configFile", "apps"], + "link-config": ["selectProjectId", "apps"], + "link-cloud": ["configFile", "apps"], + }; + const flagNames: Partial> = { + selectProjectId: "--select-project-id", + configFile: "--config-file", + apps: "--apps", + }; + + if (opts.mode) { + for (const key of incompatible[opts.mode]) { + if (opts[key] != null) { + throw new CliError(`${flagNames[key]} cannot be used with --mode ${opts.mode}.`); + } + } + } +} + async function runInit(program: Command, opts: InitOptions) { const flags = program.opts(); const outputDir = opts.outputDir ? path.resolve(opts.outputDir) : process.cwd(); + if (!fs.existsSync(outputDir)) { + throw new CliError(`Output directory does not exist: ${outputDir}`); + } + + validateOptions(opts); + console.log("Welcome to Stack Auth!\n"); - let mode: string; + let mode: "create" | "create-cloud" | "link" | "link-config" | "link-cloud"; if (opts.mode) { mode = opts.mode; } else if (opts.selectProjectId) { @@ -67,30 +99,48 @@ async function runInit(program: Command, opts: InitOptions) { } else if (opts.configFile) { mode = "link-config"; } else { - console.log("Creating a new Stack Auth project.\n"); - const location = await select({ - message: "Where would you like to create the project?", + const action = await select({ + message: "Would you like to link to an existing project, or create a new one?", choices: [ - { name: "Stack Auth Cloud", value: "hosted" as const }, - { name: "Local (requires local emulator installation, ~1.3gb storage required)", value: "local" as const }, + { name: "Create a new project", value: "create" as const }, + { name: "Link an existing project", value: "link" as const }, ], }); - mode = location === "local" ? "create" : "create-cloud"; + + if (action === "link") { + mode = "link"; + } else { + const location = await select({ + message: "Where would you like to create the project?", + choices: [ + { name: "Stack Auth Cloud", value: "hosted" as const }, + { name: "Local (requires local emulator installation, ~1.3gb storage required)", value: "local" as const }, + ], + }); + mode = location === "local" ? "create" : "create-cloud"; + } } let configPath: string | undefined; - if (mode === "link" || mode === "link-config" || mode === "link-cloud") { - const result = await handleLink(flags, opts, outputDir, mode); - configPath = result.configPath; - } else if (mode === "create") { - const result = await handleCreate(opts, outputDir); - configPath = result.configPath; - } else if (mode === "create-cloud") { - const result = await handleCreateCloud(flags, opts, outputDir); - configPath = result.configPath; - } else { - throw new CliError(`Unknown mode: ${mode}`); + switch (mode) { + case "link": + case "link-config": + case "link-cloud": { + const result = await handleLink(flags, opts, outputDir, mode); + configPath = result.configPath; + break; + } + case "create": { + const result = await handleCreate(opts, outputDir); + configPath = result.configPath; + break; + } + case "create-cloud": { + const result = await handleCreateCloud(flags, opts, outputDir); + configPath = result.configPath; + break; + } } const initPrompt = createInitPrompt(false, configPath); @@ -110,12 +160,12 @@ async function runInit(program: Command, opts: InitOptions) { } } -async function handleLink(flags: Record, opts: InitOptions, outputDir: string, resolvedMode?: string): Promise<{ configPath?: string }> { +async function handleLink(flags: Record, opts: InitOptions, outputDir: string, resolvedMode: "link" | "link-config" | "link-cloud"): Promise<{ configPath?: string }> { let source: "config-file" | "cloud"; - if (resolvedMode === "link-config" || opts.mode === "link-config") { + if (resolvedMode === "link-config") { source = "config-file"; - } else if (resolvedMode === "link-cloud" || opts.mode === "link-cloud") { + } else if (resolvedMode === "link-cloud") { source = "cloud"; } else { source = await select({ @@ -354,6 +404,21 @@ async function handleCreate(opts: InitOptions, outputDir: string): Promise<{ con const importPackage = detectImportPackageFromDir(path.dirname(configPath)); const content = renderConfigFileContent(config, importPackage); fs.mkdirSync(path.dirname(configPath), { recursive: true }); + + if (fs.existsSync(configPath)) { + if (isNonInteractiveEnv()) { + throw new CliError(`Config file already exists at ${configPath}. Refusing to overwrite in non-interactive mode.`); + } + const shouldOverwrite = await confirm({ + message: `Config file already exists at ${configPath}. Overwrite?`, + default: false, + }); + if (!shouldOverwrite) { + console.log("\nLeaving existing config file unchanged."); + return { configPath }; + } + } + fs.writeFileSync(configPath, content); console.log(`\nConfig file written to ${configPath}`);