diff --git a/packages/stack-cli/src/commands/init.ts b/packages/stack-cli/src/commands/init.ts index 8dc3ee1076..9053308566 100644 --- a/packages/stack-cli/src/commands/init.ts +++ b/packages/stack-cli/src/commands/init.ts @@ -10,11 +10,13 @@ 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"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; type InitOptions = { - mode?: "create" | "link-config" | "link-cloud", + mode?: "create" | "create-cloud" | "link-config" | "link-cloud", apps?: string, configFile?: string, selectProjectId?: string, @@ -26,7 +28,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)") @@ -51,32 +53,94 @@ 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"); - 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 }, - // ], - // }); + let mode: "create" | "create-cloud" | "link" | "link-config" | "link-cloud"; + if (opts.mode) { + mode = opts.mode; + } else if (opts.selectProjectId) { + mode = "link-cloud"; + } else if (opts.configFile) { + mode = "link-config"; + } else { + const action = await select({ + message: "Would you like to link to an existing project, or create a new one?", + choices: [ + { name: "Create a new project", value: "create" as const }, + { name: "Link an existing project", value: "link" as const }, + ], + }); + + 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); - configPath = result.configPath; - } else if (mode === "create") { - const result = await handleCreate(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); @@ -96,23 +160,21 @@ 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: "link" | "link-config" | "link-cloud"): Promise<{ configPath?: string }> { let source: "config-file" | "cloud"; - if (opts.mode === "link-config") { + if (resolvedMode === "link-config") { source = "config-file"; - } else if (opts.mode === "link-cloud") { + } else if (resolvedMode === "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") { @@ -142,10 +204,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()) { @@ -153,37 +214,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 @@ -192,11 +232,14 @@ async function handleLinkFromCloud(flags: Record, opts: InitOpt 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=${projectId}`, - `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${apiKey.publishableClientKey ?? ""}`, - `STACK_SECRET_SERVER_KEY=${apiKey.secretServerKey ?? ""}`, + `NEXT_PUBLIC_STACK_PROJECT_ID=${project.id}`, + `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${publishableClientKey}`, + `STACK_SECRET_SERVER_KEY=${secretServerKey}`, ].join("\n"); const envPath = path.resolve(outputDir, ".env"); @@ -226,7 +269,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 {}; } @@ -298,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}`); 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, + }); +}