From 384f21963ff1799698298ad2da3e518cd81ac4b9 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 11 Mar 2026 12:29:47 -0700 Subject: [PATCH 1/5] cli onboarding --- apps/e2e/tests/general/cli.test.ts | 115 +++++++++ packages/stack-cli/package.json | 2 + packages/stack-cli/src/commands/init.ts | 275 +++++++++++++++++++- packages/stack-cli/src/lib/init-prompt.ts | 113 ++++++++ pnpm-lock.yaml | 301 ++++++++++++++++++++-- 5 files changed, 779 insertions(+), 27 deletions(-) create mode 100644 packages/stack-cli/src/lib/init-prompt.ts diff --git a/apps/e2e/tests/general/cli.test.ts b/apps/e2e/tests/general/cli.test.ts index f5e8cebf49..bb8a9ebbde 100644 --- a/apps/e2e/tests/general/cli.test.ts +++ b/apps/e2e/tests/general/cli.test.ts @@ -345,4 +345,119 @@ describe("Stack CLI", () => { expect(exitCode).toBe(1); expect(stderr).toContain("plain `config` object"); }); + + // --- init command tests --- + + it("init create writes stack.config.ts with selected apps", async ({ expect }) => { + const initDir = path.join(tmpDir, "init-create"); + fs.mkdirSync(initDir, { recursive: true }); + + const { stdout, exitCode } = await runCli([ + "init", "--mode", "create", "--apps", "authentication,teams", "--output-dir", initDir, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Config file written to"); + + const content = fs.readFileSync(path.join(initDir, "stack.config.ts"), "utf-8"); + expect(content).toContain("export const config"); + const configMatch = content.match(/export const config = (.+);/s); + expect(configMatch).toBeTruthy(); + const parsed = JSON.parse(configMatch![1]); + expect(parsed.apps.installed.authentication).toEqual({ enabled: true }); + expect(parsed.apps.installed.teams).toEqual({ enabled: true }); + }); + + it("init create with single app", async ({ expect }) => { + const initDir = path.join(tmpDir, "init-create-single"); + fs.mkdirSync(initDir, { recursive: true }); + + const { stdout, exitCode } = await runCli([ + "init", "--mode", "create", "--apps", "authentication", "--output-dir", initDir, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Config file written to"); + + const content = fs.readFileSync(path.join(initDir, "stack.config.ts"), "utf-8"); + const configMatch = content.match(/export const config = (.+);/s); + const parsed = JSON.parse(configMatch![1]); + expect(Object.keys(parsed.apps.installed)).toEqual(["authentication"]); + }); + + it("init link-config with valid path", async ({ expect }) => { + // Create a dummy config file to link to + const dummyConfig = path.join(tmpDir, "dummy-stack.config.ts"); + fs.writeFileSync(dummyConfig, "export const config = {};\n"); + + const { stdout, exitCode } = await runCli([ + "init", "--mode", "link-config", "--config-file", dummyConfig, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Linked to config file"); + expect(stdout).toContain(dummyConfig); + }); + + it("init link-config with invalid path fails", async ({ expect }) => { + const { stderr, exitCode } = await runCli([ + "init", "--mode", "link-config", "--config-file", "/nonexistent/stack.config.ts", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("File not found"); + }); + + it("init link-cloud creates .env with API keys", async ({ expect }) => { + expect(createdProjectId).toBeDefined(); + + const initDir = path.join(tmpDir, "init-cloud"); + fs.mkdirSync(initDir, { recursive: true }); + + const { stdout, exitCode } = await runCli([ + "init", "--mode", "link-cloud", "--select-project-id", createdProjectId, "--output-dir", initDir, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Created .env with Stack Auth keys"); + + const envContent = fs.readFileSync(path.join(initDir, ".env"), "utf-8"); + expect(envContent).toContain("# Stack Auth"); + expect(envContent).toContain(`NEXT_PUBLIC_STACK_PROJECT_ID=${createdProjectId}`); + expect(envContent).toContain("NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY="); + expect(envContent).toContain("STACK_SECRET_SERVER_KEY="); + }); + + it("init link-cloud appends to existing .env", async ({ expect }) => { + expect(createdProjectId).toBeDefined(); + + const initDir = path.join(tmpDir, "init-cloud-append"); + fs.mkdirSync(initDir, { recursive: true }); + fs.writeFileSync(path.join(initDir, ".env"), "EXISTING_VAR=hello\n"); + + const { stdout, exitCode } = await runCli([ + "init", "--mode", "link-cloud", "--select-project-id", createdProjectId, "--output-dir", initDir, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("Appended Stack Auth keys to .env"); + + const envContent = fs.readFileSync(path.join(initDir, ".env"), "utf-8"); + expect(envContent).toContain("EXISTING_VAR=hello"); + expect(envContent).toContain("# Stack Auth"); + expect(envContent).toContain(`NEXT_PUBLIC_STACK_PROJECT_ID=${createdProjectId}`); + }); + + it("init link-cloud fails with invalid project ID", async ({ expect }) => { + const { stderr, exitCode } = await runCli([ + "init", "--mode", "link-cloud", "--select-project-id", "nonexistent-project-id", + ]); + expect(exitCode).toBe(1); + expect(stderr).toContain("not found"); + }); + + it("init outputs setup instructions", async ({ expect }) => { + const initDir = path.join(tmpDir, "init-instructions"); + fs.mkdirSync(initDir, { recursive: true }); + + const { stdout, exitCode } = await runCli([ + "init", "--mode", "create", "--apps", "authentication", "--output-dir", initDir, + ]); + expect(exitCode).toBe(0); + expect(stdout).toContain("STACK AUTH SETUP INSTRUCTIONS"); + }); }); diff --git a/packages/stack-cli/package.json b/packages/stack-cli/package.json index ea713ec540..d809061026 100644 --- a/packages/stack-cli/package.json +++ b/packages/stack-cli/package.json @@ -26,7 +26,9 @@ "author": "", "license": "MIT", "dependencies": { + "@inquirer/prompts": "^7.0.0", "@stackframe/js": "workspace:*", + "@stackframe/stack-shared": "workspace:*", "commander": "^13.1.0", "jiti": "^2.4.2" }, diff --git a/packages/stack-cli/src/commands/init.ts b/packages/stack-cli/src/commands/init.ts index 2ebf03baaa..038a5e05fc 100644 --- a/packages/stack-cli/src/commands/init.ts +++ b/packages/stack-cli/src/commands/init.ts @@ -1,16 +1,275 @@ import { Command } from "commander"; -import { execFileSync } from "child_process"; +import { select, input, checkbox, confirm } from "@inquirer/prompts"; +import * as fs from "fs"; +import * as path from "path"; +import { StackClientApp } from "@stackframe/js"; +import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config"; +import { resolveLoginConfig, resolveSessionAuth, DEFAULT_PUBLISHABLE_CLIENT_KEY } from "../lib/auth.js"; +import { getInternalUser } from "../lib/app.js"; +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"; + +type InitOptions = { + mode?: "create" | "link-config" | "link-cloud", + apps?: string, + configFile?: string, + selectProjectId?: string, + outputDir?: string, +}; export function registerInitCommand(program: Command) { program .command("init") - .description("Initialize Stack Auth in your project (delegates to @stackframe/init-stack)") - .allowUnknownOption(true) - .helpOption(false) - .action((_opts, cmd) => { - const args = cmd.args as string[]; - execFileSync("npx", ["@stackframe/init-stack@latest", ...args], { - stdio: "inherit", + .description("Initialize Stack Auth in your project") + .option("--mode ", "Mode: create, 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)") + .option("--output-dir ", "Directory to write output files (defaults to cwd)") + .action(async (opts: InitOptions) => { + const hasFlags = opts.mode != null; + + if (!hasFlags && isNonInteractiveEnv()) { + throw new CliError("stack init requires an interactive terminal. Use --mode flag for non-interactive usage."); + } + + try { + await runInit(program, opts); + } catch (error: unknown) { + if (error != null && typeof error === "object" && "name" in error && error.name === "ExitPromptError") { + console.log("\nAborted."); + process.exit(0); + } + throw error; + } + }); +} + +async function runInit(program: Command, opts: InitOptions) { + const flags = program.opts(); + const outputDir = opts.outputDir ? path.resolve(opts.outputDir) : process.cwd(); + + 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: "Create a new project (local emulator)", value: "create" as const }, + { name: "Link an existing project", value: "link" as const }, + ], + }); + + 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}`); + } + + console.log("\n" + createInitPrompt(false, configPath)); +} + +async function handleLink(flags: Record, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> { + let source: "config-file" | "cloud"; + + if (opts.mode === "link-config") { + source = "config-file"; + } else if (opts.mode === "link-cloud") { + source = "cloud"; + } else { + 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") { + return await handleLinkFromConfigFile(opts); + } + return await handleLinkFromCloud(flags, opts, outputDir); +} + +async function handleLinkFromConfigFile(opts: InitOptions): Promise<{ configPath: string }> { + const filePath = opts.configFile ?? await input({ + message: "Path to your existing stack.config.ts:", + validate: (value) => { + const resolved = path.resolve(value); + if (!fs.existsSync(resolved)) { + return `File not found: ${resolved}`; + } + return true; + }, + }); + + const configPath = path.resolve(filePath); + if (!fs.existsSync(configPath)) { + throw new CliError(`File not found: ${configPath}`); + } + + console.log(`\nLinked to config file: ${configPath}`); + return { configPath }; +} + +async function handleLinkFromCloud(flags: Record, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> { + let sessionAuth; + try { + sessionAuth = resolveSessionAuth(flags as { projectId?: string }); + } catch (e) { + if (e instanceof AuthError) { + if (isNonInteractiveEnv()) { + throw new CliError("Not logged in. Run `stack login` first or set STACK_CLI_REFRESH_TOKEN."); + } + console.log("You need to log in first.\n"); + await performLogin(flags); + sessionAuth = resolveSessionAuth(flags as { projectId?: string }); + } else { + 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)!; + const apiKey = await project.app.createInternalApiKey({ + description: "Created by stack init", + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 200), // 200 years + hasPublishableClientKey: true, + hasSecretServerKey: true, + hasSuperSecretAdminKey: false, + }); + + const envLines = [ + "# Stack Auth", + `NEXT_PUBLIC_STACK_PROJECT_ID=${projectId}`, + `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${apiKey.publishableClientKey ?? ""}`, + `STACK_SECRET_SERVER_KEY=${apiKey.secretServerKey ?? ""}`, + ].join("\n"); + + const envPath = path.resolve(outputDir, ".env"); + + if (fs.existsSync(envPath)) { + if (isNonInteractiveEnv()) { + fs.appendFileSync(envPath, "\n" + envLines + "\n"); + console.log("\nAppended Stack Auth keys to .env"); + } else { + const shouldAppend = await confirm({ + message: `.env file already exists. Append Stack Auth keys?`, + default: true, }); + + if (shouldAppend) { + fs.appendFileSync(envPath, "\n" + envLines + "\n"); + console.log("\nAppended Stack Auth keys to .env"); + } else { + console.log("\nHere are your environment variables:\n"); + console.log(envLines); + } + } + } else { + fs.writeFileSync(envPath, envLines + "\n"); + console.log("\nCreated .env with Stack Auth keys"); + } + + return {}; +} + +async function performLogin(flags: Record) { + const config = resolveLoginConfig(flags as { projectId?: string }); + + const app = new StackClientApp({ + projectId: "internal", + publishableClientKey: DEFAULT_PUBLISHABLE_CLIENT_KEY, + baseUrl: config.apiUrl, + tokenStore: "memory", + noAutomaticPrefetch: true, + }); + + console.log("Waiting for browser authentication..."); + + const result = await app.promptCliLogin({ + appUrl: config.dashboardUrl, + }); + + if (result.status === "error") { + throw new CliError(`Login failed: ${result.error.message}`); + } + + writeConfigValue("STACK_CLI_REFRESH_TOKEN", result.data); + console.log("Login successful!\n"); +} + +async function handleCreate(opts: InitOptions, outputDir: string): Promise<{ configPath: string }> { + const configPath = path.resolve(outputDir, "stack.config.ts"); + + console.log(`\nCreating a new config file at ${configPath}!\n`); + + let selectedApps: string[]; + + if (opts.apps) { + selectedApps = opts.apps.split(",").map((s) => s.trim()).filter(Boolean); + } else { + const stageOrder = { stable: 0, beta: 1 } as const; + const appEntries = Object.entries(ALL_APPS) + .filter(([, app]) => app.stage !== "alpha") + .sort((a, b) => stageOrder[a[1].stage as keyof typeof stageOrder] - stageOrder[b[1].stage as keyof typeof stageOrder]); + + selectedApps = await checkbox({ + message: "Select apps to enable:", + choices: appEntries.map(([id, app]) => ({ + name: `${app.displayName} - ${app.subtitle}${app.stage !== "stable" ? ` (${app.stage})` : ""}`, + value: id, + checked: id === "authentication", + })), }); + } + + const installed: Record = {}; + for (const appId of selectedApps) { + installed[appId] = { enabled: true }; + } + + const config = { + apps: { + installed, + }, + }; + + const content = `export const config = ${JSON.stringify(config, null, 2)};\n`; + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, content); + + console.log(`\nConfig file written to ${configPath}`); + return { configPath }; } diff --git a/packages/stack-cli/src/lib/init-prompt.ts b/packages/stack-cli/src/lib/init-prompt.ts new file mode 100644 index 0000000000..6705b824cb --- /dev/null +++ b/packages/stack-cli/src/lib/init-prompt.ts @@ -0,0 +1,113 @@ +export const createInitPrompt = (web: boolean, configPath?: string) => `============================= +STACK AUTH SETUP INSTRUCTIONS +============================= + +These instructions describe how to set up Stack Auth. +${web ? ` +First of all, if you have access to a terminal, it is better to use the \`npx @stackframe/stack-cli init\`` : ""} + +Install mcp server from https://mcp.stack-auth.com if not already installed + +For reference, questions, or information on Stack Auth, you can query the docs on https://docs.stack-auth.com via curl or any tools that you have + +## Setup + +### 1) Install the package + +Run the install command using whatever package manager the project uses (npm, yarn, pnpm, bun): + +| Framework | Package | +|-----------|---------| +| Next.js | \`@stackframe/stack\` | +| React | \`@stackframe/react\` | +| Vanilla JS | \`@stackframe/js\` | + +### 2) Create the Stack apps + +Depending on whether you're on a client or a server, you will want to create stackClientApp or stackServerApp. Some environments, like Next.js, have both, so create both files. + +The stack client app has client-level permissions. It contains most of the useful methods and hooks for your client-side code. +The stack server app has full read and write access to all users. It requires STACK_SECRET_SERVER_KEY env variable and should only be used in secure context + +For example: + +\`\`\`ts +// src/stack/client.ts +import { StackClientApp } from "@stackframe/stack"; + +export const stackClientApp = new StackClientApp({ + tokenStore: "nextjs-cookie", // or "cookie" +}); +\`\`\` + +and/or + +\`\`\`ts +// src/stack/server.ts +import "server-only"; +import { StackServerApp } from "@stackframe/stack"; +import { stackClientApp } from "./client"; + +export const stackServerApp = new StackServerApp({ + inheritsFrom: stackClientApp, +}); +\`\`\` + +### 3) Create the Stack handler (if available in framework) + +This sets up pages for sign in, sign up, password reset, etc. + +\`\`\`tsx +import { StackHandler } from "@stackframe/stack"; // Next.js +// import { StackHandler } from "@stackframe/react"; // React + +export default function Handler() { + return ; +} +\`\`\` + +### 4) Create a Suspense boundary + +Suspense is necessary for many stack auth hooks such as useUser to function. Add a loading component with a custom loading indicator for the current project. Don't add if one already exists + +For example: +\`\`\`tsx +//src/loading.tsx + +export default function Loading() { + return

Loading...

+} +\`\`\` + +### 5) Link environment variables + +This is only necessary if not using local emulator. Ensure these are ignored by git. + +\`\`\` +NEXT_PUBLIC_STACK_PROJECT_ID= +NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY= +STACK_SECRET_SERVER_KEY= +\`\`\` + +### 6) React only: Wrap the entire page in a Stack provider + +This is used for the useUser and useStackApp hooks. + +\`\`\`tsx +import { StackProvider, StackTheme } from "@stackframe/stack"; +import { stackClientApp } from "../stack/client"; // adjust relative path +\`\`\` + +Then wrap the body content: + +\`\`\`tsx +return ( + + + {children} + + +); +\`\`\` +`; + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 741fe12c8d..3e282f59d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1906,9 +1906,15 @@ importers: packages/stack-cli: dependencies: + '@inquirer/prompts': + specifier: ^7.0.0 + version: 7.10.1(@types/node@20.17.6) '@stackframe/js': specifier: workspace:* version: link:../js + '@stackframe/stack-shared': + specifier: workspace:* + version: link:../stack-shared commander: specifier: ^13.1.0 version: 13.1.0 @@ -4763,10 +4769,144 @@ packages: cpu: [x64] os: [win32] + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + '@inquirer/figures@1.0.3': resolution: {integrity: sha512-ErXXzENMH5pJt5/ssXV0DfWUZqly8nGzf0UcBV9xTnP+KyffE2mqyxIMBrZ8ijQck2nU0TQm40EQB53YreyWHw==} engines: {node: '>=18'} + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -10010,6 +10150,9 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + check-error@1.0.3: resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} @@ -13374,6 +13517,10 @@ packages: resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + mysql2@3.15.3: resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} engines: {node: '>= 8.0'} @@ -16647,6 +16794,10 @@ packages: resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} engines: {node: '>=12.20'} + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + yup@1.7.1: resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} @@ -19417,8 +19568,133 @@ snapshots: '@img/sharp-win32-x64@0.34.4': optional: true + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@20.17.6)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@20.17.6) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.17.6) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.17.6 + + '@inquirer/confirm@5.1.21(@types/node@20.17.6)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.17.6) + '@inquirer/type': 3.0.10(@types/node@20.17.6) + optionalDependencies: + '@types/node': 20.17.6 + + '@inquirer/core@10.3.2(@types/node@20.17.6)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.17.6) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.17.6 + + '@inquirer/editor@4.2.23(@types/node@20.17.6)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.17.6) + '@inquirer/external-editor': 1.0.3(@types/node@20.17.6) + '@inquirer/type': 3.0.10(@types/node@20.17.6) + optionalDependencies: + '@types/node': 20.17.6 + + '@inquirer/expand@4.0.23(@types/node@20.17.6)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.17.6) + '@inquirer/type': 3.0.10(@types/node@20.17.6) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.17.6 + + '@inquirer/external-editor@1.0.3(@types/node@20.17.6)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.0 + optionalDependencies: + '@types/node': 20.17.6 + + '@inquirer/figures@1.0.15': {} + '@inquirer/figures@1.0.3': {} + '@inquirer/input@4.3.1(@types/node@20.17.6)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.17.6) + '@inquirer/type': 3.0.10(@types/node@20.17.6) + optionalDependencies: + '@types/node': 20.17.6 + + '@inquirer/number@3.0.23(@types/node@20.17.6)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.17.6) + '@inquirer/type': 3.0.10(@types/node@20.17.6) + optionalDependencies: + '@types/node': 20.17.6 + + '@inquirer/password@4.0.23(@types/node@20.17.6)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@20.17.6) + '@inquirer/type': 3.0.10(@types/node@20.17.6) + optionalDependencies: + '@types/node': 20.17.6 + + '@inquirer/prompts@7.10.1(@types/node@20.17.6)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@20.17.6) + '@inquirer/confirm': 5.1.21(@types/node@20.17.6) + '@inquirer/editor': 4.2.23(@types/node@20.17.6) + '@inquirer/expand': 4.0.23(@types/node@20.17.6) + '@inquirer/input': 4.3.1(@types/node@20.17.6) + '@inquirer/number': 3.0.23(@types/node@20.17.6) + '@inquirer/password': 4.0.23(@types/node@20.17.6) + '@inquirer/rawlist': 4.1.11(@types/node@20.17.6) + '@inquirer/search': 3.2.2(@types/node@20.17.6) + '@inquirer/select': 4.4.2(@types/node@20.17.6) + optionalDependencies: + '@types/node': 20.17.6 + + '@inquirer/rawlist@4.1.11(@types/node@20.17.6)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.17.6) + '@inquirer/type': 3.0.10(@types/node@20.17.6) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.17.6 + + '@inquirer/search@3.2.2(@types/node@20.17.6)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@20.17.6) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.17.6) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.17.6 + + '@inquirer/select@4.4.2(@types/node@20.17.6)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@20.17.6) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@20.17.6) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 20.17.6 + + '@inquirer/type@3.0.10(@types/node@20.17.6)': + optionalDependencies: + '@types/node': 20.17.6 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -26658,6 +26934,8 @@ snapshots: chardet@0.7.0: {} + chardet@2.1.1: {} + check-error@1.0.3: dependencies: get-func-name: 2.0.2 @@ -28096,25 +28374,6 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 - enhanced-resolve: 5.17.1 - eslint: 8.57.1 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) - fast-glob: 3.3.3 - get-tsconfig: 4.8.1 - is-bun-module: 1.2.1 - is-glob: 4.0.3 - optionalDependencies: - eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) - transitivePeerDependencies: - - '@typescript-eslint/parser' - - eslint-import-resolver-node - - eslint-import-resolver-webpack - - supports-color - eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -31082,6 +31341,8 @@ snapshots: mute-stream@1.0.0: {} + mute-stream@2.0.0: {} + mysql2@3.15.3: dependencies: aws-ssl-profiles: 1.1.2 @@ -35142,6 +35403,8 @@ snapshots: yocto-queue@1.1.1: {} + yoctocolors-cjs@2.1.3: {} + yup@1.7.1: dependencies: property-expr: 2.0.6 From 075b22e77e90b758af3a301921442c5cb64b22ee Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 11 Mar 2026 14:12:14 -0700 Subject: [PATCH 2/5] Fix PR review feedback: prototype pollution, .env append, and app ID validation --- packages/stack-cli/src/commands/init.ts | 19 +++++++++++++------ packages/stack-cli/src/lib/init-prompt.ts | 2 ++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/stack-cli/src/commands/init.ts b/packages/stack-cli/src/commands/init.ts index 038a5e05fc..c97559dabd 100644 --- a/packages/stack-cli/src/commands/init.ts +++ b/packages/stack-cli/src/commands/init.ts @@ -180,8 +180,11 @@ async function handleLinkFromCloud(flags: Record, opts: InitOpt const envPath = path.resolve(outputDir, ".env"); if (fs.existsSync(envPath)) { + const existing = fs.readFileSync(envPath, "utf-8"); + const separator = existing.endsWith("\n") ? "\n" : "\n\n"; + if (isNonInteractiveEnv()) { - fs.appendFileSync(envPath, "\n" + envLines + "\n"); + fs.appendFileSync(envPath, separator + envLines + "\n"); console.log("\nAppended Stack Auth keys to .env"); } else { const shouldAppend = await confirm({ @@ -190,7 +193,7 @@ async function handleLinkFromCloud(flags: Record, opts: InitOpt }); if (shouldAppend) { - fs.appendFileSync(envPath, "\n" + envLines + "\n"); + fs.appendFileSync(envPath, separator + envLines + "\n"); console.log("\nAppended Stack Auth keys to .env"); } else { console.log("\nHere are your environment variables:\n"); @@ -239,6 +242,11 @@ async function handleCreate(opts: InitOptions, outputDir: string): Promise<{ con if (opts.apps) { selectedApps = opts.apps.split(",").map((s) => s.trim()).filter(Boolean); + const validAppIds = Object.keys(ALL_APPS); + const invalidApps = selectedApps.filter((id) => !validAppIds.includes(id)); + if (invalidApps.length > 0) { + throw new CliError(`Unknown app IDs: ${invalidApps.join(", ")}. Valid IDs: ${validAppIds.join(", ")}`); + } } else { const stageOrder = { stable: 0, beta: 1 } as const; const appEntries = Object.entries(ALL_APPS) @@ -255,10 +263,9 @@ async function handleCreate(opts: InitOptions, outputDir: string): Promise<{ con }); } - const installed: Record = {}; - for (const appId of selectedApps) { - installed[appId] = { enabled: true }; - } + const installed = Object.fromEntries( + selectedApps.map((appId) => [appId, { enabled: true }]) + ); const config = { apps: { diff --git a/packages/stack-cli/src/lib/init-prompt.ts b/packages/stack-cli/src/lib/init-prompt.ts index 6705b824cb..c9d51b782c 100644 --- a/packages/stack-cli/src/lib/init-prompt.ts +++ b/packages/stack-cli/src/lib/init-prompt.ts @@ -1,3 +1,5 @@ +// TODO: Use configPath in the prompt once local emulator is set up: +// Add "npx @stackframe/stack-cli emulator run --config-file ${configPath}" to project dev command export const createInitPrompt = (web: boolean, configPath?: string) => `============================= STACK AUTH SETUP INSTRUCTIONS ============================= From ccdc469ad04fdfe8df1eac4c359f86184146f3bb Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 12 Mar 2026 11:53:01 -0700 Subject: [PATCH 3/5] claude agent proxy, small fixes --- .../ai-proxy/[[...path]]/route.ts | 71 ++++++ packages/stack-cli/package.json | 1 + packages/stack-cli/src/commands/init.ts | 19 +- packages/stack-cli/src/lib/claude-agent.ts | 207 ++++++++++++++++++ packages/stack-cli/src/lib/init-prompt.ts | 25 ++- packages/stack-cli/tsdown.config.ts | 1 + pnpm-lock.yaml | 88 +++++--- pnpm-workspace.yaml | 1 + 8 files changed, 370 insertions(+), 43 deletions(-) create mode 100644 apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts create mode 100644 packages/stack-cli/src/lib/claude-agent.ts diff --git a/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts b/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts new file mode 100644 index 0000000000..1aa9deb751 --- /dev/null +++ b/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts @@ -0,0 +1,71 @@ +import { handleApiRequest } from "@/route-handlers/smart-route-handler"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { NextRequest } from "next/server"; + +const OPENROUTER_BASE_URL = "https://openrouter.ai/api"; +const OPENROUTER_MODEL = "anthropic/claude-sonnet-4.6"; + +function getApiKey(): string { + const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY", ""); + if (!apiKey) { + throw new StatusError(503, "AI proxy not configured"); + } + return apiKey; +} + +function sanitizeBody(raw: ArrayBuffer): Uint8Array { + const text = new TextDecoder().decode(raw); + const parsed = JSON.parse(text); + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new StatusError(400, "Request body must be a JSON object"); + } + + parsed.model = OPENROUTER_MODEL; + + // OpenRouter limits metadata.user_id to 128 characters + if (parsed.metadata?.user_id && parsed.metadata.user_id.length > 128) { + parsed.metadata.user_id = parsed.metadata.user_id.slice(0, 128); + } + + return new TextEncoder().encode(JSON.stringify(parsed)); +} + +async function proxyToOpenRouter(req: NextRequest, options: { params: Promise<{ path?: string[] }> }) { + const apiKey = getApiKey(); + const params = await options.params; + const subpath = params.path?.join("/") ?? ""; + const targetUrl = `${OPENROUTER_BASE_URL}/${subpath}${req.nextUrl.search}`; + + const headers: Record = { + "Authorization": `Bearer ${apiKey}`, + "anthropic-version": "2023-06-01", + }; + + const contentType = req.headers.get("Content-Type"); + if (contentType) { + headers["Content-Type"] = contentType; + } + + const body = req.method !== "GET" && req.method !== "HEAD" + ? Buffer.from(sanitizeBody(await req.arrayBuffer())) + : undefined; + + const response = await fetch(targetUrl, { + method: req.method, + headers, + body, + }); + + return new Response(response.body, { + status: response.status, + headers: { + "Content-Type": response.headers.get("Content-Type") ?? "application/json", + "Cache-Control": "no-cache", + }, + }); +} + +export const GET = handleApiRequest(proxyToOpenRouter); +export const POST = handleApiRequest(proxyToOpenRouter); diff --git a/packages/stack-cli/package.json b/packages/stack-cli/package.json index d809061026..4e8f0d526b 100644 --- a/packages/stack-cli/package.json +++ b/packages/stack-cli/package.json @@ -26,6 +26,7 @@ "author": "", "license": "MIT", "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.73", "@inquirer/prompts": "^7.0.0", "@stackframe/js": "workspace:*", "@stackframe/stack-shared": "workspace:*", diff --git a/packages/stack-cli/src/commands/init.ts b/packages/stack-cli/src/commands/init.ts index c97559dabd..fc12b3158a 100644 --- a/packages/stack-cli/src/commands/init.ts +++ b/packages/stack-cli/src/commands/init.ts @@ -10,6 +10,7 @@ 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 { runClaudeAgent } from "../lib/claude-agent.js"; type InitOptions = { mode?: "create" | "link-config" | "link-cloud", @@ -17,6 +18,7 @@ type InitOptions = { configFile?: string, selectProjectId?: string, outputDir?: string, + agent?: boolean, }; export function registerInitCommand(program: Command) { @@ -28,6 +30,7 @@ export function registerInitCommand(program: Command) { .option("--config-file ", "Path to existing config file (for link-config mode)") .option("--select-project-id ", "Project ID to link (for link-cloud mode)") .option("--output-dir ", "Directory to write output files (defaults to cwd)") + .option("--no-agent", "Skip Claude agent and print setup instructions instead") .action(async (opts: InitOptions) => { const hasFlags = opts.mode != null; @@ -73,7 +76,21 @@ async function runInit(program: Command, opts: InitOptions) { throw new CliError(`Unknown mode: ${mode}`); } - console.log("\n" + createInitPrompt(false, configPath)); + const initPrompt = createInitPrompt(false, configPath); + const useAgent = opts.agent !== false && !isNonInteractiveEnv(); + + if (useAgent) { + const success = await runClaudeAgent({ + prompt: `Execute ALL of the following setup steps in my project now. Do not ask questions — just detect the framework and package manager from existing files and proceed.\n\n${initPrompt}`, + cwd: outputDir, + }); + if (!success) { + console.log("\nFalling back to manual instructions:\n"); + console.log(initPrompt); + } + } else { + console.log("\n" + initPrompt); + } } async function handleLink(flags: Record, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> { diff --git a/packages/stack-cli/src/lib/claude-agent.ts b/packages/stack-cli/src/lib/claude-agent.ts new file mode 100644 index 0000000000..dcac7bd6e7 --- /dev/null +++ b/packages/stack-cli/src/lib/claude-agent.ts @@ -0,0 +1,207 @@ +import { query } from "@anthropic-ai/claude-agent-sdk"; + +const DEFAULT_PROXY_URL = "https://api.stack-auth.com/api/v1/integrations/ai-proxy"; +const ANTHROPIC_PROXY_BASE_URL: string = process.env.STACK_CLAUDE_PROXY_URL ?? DEFAULT_PROXY_URL; + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +class AgentProgressUI { + private mainLabel: string; + private spinnerFrame = 0; + private spinnerTimer: ReturnType | null = null; + private activeSpinners = new Map(); // id -> label + private flushedCount = 0; // number of completed items already printed above the spinner area + private pendingCompleted: string[] = []; // completed items not yet flushed + private lastLineCount = 0; + + constructor(mainLabel: string) { + this.mainLabel = mainLabel; + } + + start() { + this.spinnerTimer = setInterval(() => { + this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length; + this.render(); + }, 80); + this.render(); + } + + stop(success: boolean) { + if (this.spinnerTimer) { + clearInterval(this.spinnerTimer); + this.spinnerTimer = null; + } + this.completeAllActive(); + this.clearLines(); + const icon = success ? "\x1b[32m✔\x1b[0m" : "\x1b[31m✖\x1b[0m"; + // Re-print header + all completed items as final output + console.log(`${icon} ${this.mainLabel}`); + for (const label of this.pendingCompleted) { + console.log(` \x1b[32m✔\x1b[0m ${label}`); + } + this.pendingCompleted = []; + } + + setSpinner(id: string, label: string) { + this.activeSpinners.set(id, label); + } + + complete(id: string, label?: string) { + const existing = this.activeSpinners.get(id); + this.activeSpinners.delete(id); + const finalLabel = label ?? existing; + if (finalLabel) { + this.pendingCompleted.push(finalLabel); + } + } + + completeAllActive() { + for (const label of this.activeSpinners.values()) { + this.pendingCompleted.push(label); + } + this.activeSpinners.clear(); + } + + private clearLines() { + if (this.lastLineCount > 0) { + process.stdout.write(`\x1b[${this.lastLineCount}A\x1b[J`); + } + } + + private flushCompleted() { + if (this.pendingCompleted.length === 0) { + return; + } + // Clear the spinner area, print completed items permanently, then re-render spinner below + this.clearLines(); + // Re-print the header line if this is the first flush + if (this.flushedCount === 0) { + const frame = SPINNER_FRAMES[this.spinnerFrame]; + process.stdout.write(`\x1b[36m${frame}\x1b[0m ${this.mainLabel}\n`); + } + for (const label of this.pendingCompleted) { + process.stdout.write(` \x1b[32m✔\x1b[0m ${label}\n`); + } + this.flushedCount += this.pendingCompleted.length; + this.pendingCompleted = []; + this.lastLineCount = 0; // reset since we printed permanent lines + } + + private render() { + this.flushCompleted(); + this.clearLines(); + + const frame = SPINNER_FRAMES[this.spinnerFrame]; + const lines: string[] = []; + + // Only show header in spinner area if nothing has been flushed yet + if (this.flushedCount === 0) { + lines.push(`\x1b[36m${frame}\x1b[0m ${this.mainLabel}`); + } + + for (const label of this.activeSpinners.values()) { + lines.push(` \x1b[36m${frame}\x1b[0m ${label}`); + } + + if (lines.length > 0) { + const output = lines.join("\n") + "\n"; + process.stdout.write(output); + } + this.lastLineCount = lines.length; + } +} + +function getToolLabel(toolName: string, input: Record): string { + switch (toolName) { + case "Read": { + return `Reading ${input.file_path ?? "file"}`; + } + case "Write": { + return `Writing ${input.file_path ?? "file"}`; + } + case "Edit": { + return `Editing ${input.file_path ?? "file"}`; + } + case "Bash": { + return `Running \`${truncate(String(input.command ?? ""), 40)}\``; + } + case "Glob": { + return `Searching for ${input.pattern ?? "files"}`; + } + case "Grep": { + return `Searching for "${truncate(String(input.pattern ?? ""), 30)}"`; + } + default: { + return toolName; + } + } +} + +function truncate(str: string, maxLen: number): string { + return str.length > maxLen ? str.slice(0, maxLen - 1) + "…" : str; +} + +function stripClaudeCodeEnv(): Record { + const env = { ...process.env }; + delete env.CLAUDECODE; + return env as Record; +} + +export async function runClaudeAgent(options: { + prompt: string, + cwd: string, +}): Promise { + const ui = new AgentProgressUI("Setting up Stack Auth..."); + ui.start(); + + try { + let resultText = ""; + + for await (const message of query({ + prompt: options.prompt, + options: { + allowedTools: ["Read", "Write", "Edit", "Bash", "Glob", "Grep"], + permissionMode: "dontAsk", + cwd: options.cwd, + // stripClaudeCodeEnv removes CLAUDECODE env var to prevent nested agent detection + env: { ...stripClaudeCodeEnv(), ANTHROPIC_BASE_URL: ANTHROPIC_PROXY_BASE_URL, ANTHROPIC_API_KEY: "" }, + stderr: (data: string) => { process.stderr.write(data); }, + }, + })) { + if ("result" in message) { + resultText = message.result; + } else if (message.type === "assistant" && message.parent_tool_use_id === null) { + // New parent assistant turn — previous tools are done + ui.completeAllActive(); + // Register new tool calls from this turn + for (const block of message.message.content) { + if (block.type === "tool_use") { + ui.setSpinner(block.id, getToolLabel(block.name, block.input as Record)); + } + } + } else if (message.type === "system") { + // Subagent task lifecycle + const msg = message as Record; + const taskId = msg.task_id as string | undefined; + + if (msg.subtype === "task_started" && taskId) { + ui.setSpinner(taskId, String(msg.description ?? "Working...")); + } else if (msg.subtype === "task_progress" && taskId) { + ui.setSpinner(taskId, String(msg.description ?? "Working...")); + } else if (msg.subtype === "task_notification" && taskId) { + ui.complete(taskId, String(msg.summary ?? msg.description ?? "Done")); + } + } + } + + ui.stop(true); + if (resultText) { + console.log(`\n${resultText}`); + } + return true; + } catch (error) { + ui.stop(false); + console.error("\nClaude agent encountered an error:", error instanceof Error ? error.message : error); + return false; + } +} diff --git a/packages/stack-cli/src/lib/init-prompt.ts b/packages/stack-cli/src/lib/init-prompt.ts index c9d51b782c..8260aaa0c2 100644 --- a/packages/stack-cli/src/lib/init-prompt.ts +++ b/packages/stack-cli/src/lib/init-prompt.ts @@ -31,18 +31,24 @@ Depending on whether you're on a client or a server, you will want to create sta The stack client app has client-level permissions. It contains most of the useful methods and hooks for your client-side code. The stack server app has full read and write access to all users. It requires STACK_SECRET_SERVER_KEY env variable and should only be used in secure context -For example: +In Next.js, env vars are auto-detected (NEXT_PUBLIC_STACK_PROJECT_ID etc.), so the constructor needs no explicit config. For other frameworks, you must pass projectId and publishableClientKey explicitly using the framework's env var access method. + +The tokenStore should be "nextjs-cookie" for Next.js, or "cookie" for all other frameworks. \`\`\`ts // src/stack/client.ts -import { StackClientApp } from "@stackframe/stack"; +import { StackClientApp } from "@stackframe/stack"; // or "@stackframe/react" or "@stackframe/js" export const stackClientApp = new StackClientApp({ - tokenStore: "nextjs-cookie", // or "cookie" + // Next.js: omit projectId/publishableClientKey (auto-detected from NEXT_PUBLIC_ env vars) + // Other frameworks: pass explicitly, e.g. for Vite: + // projectId: import.meta.env.VITE_STACK_PROJECT_ID, + // publishableClientKey: import.meta.env.VITE_STACK_PUBLISHABLE_CLIENT_KEY, + tokenStore: "nextjs-cookie", // or "cookie" for non-Next.js }); \`\`\` -and/or +If the framework has server-side support (e.g. Next.js), also create a server app: \`\`\`ts // src/stack/server.ts @@ -85,11 +91,12 @@ export default function Loading() { This is only necessary if not using local emulator. Ensure these are ignored by git. -\`\`\` -NEXT_PUBLIC_STACK_PROJECT_ID= -NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY= -STACK_SECRET_SERVER_KEY= -\`\`\` +Rename the env var keys in .env to match the framework's convention for client-exposed variables. For example, Vite requires VITE_ prefix, Next.js uses NEXT_PUBLIC_, etc. The values should stay the same — only rename the keys. + +The required variables are: +- Project ID (e.g. NEXT_PUBLIC_STACK_PROJECT_ID, VITE_STACK_PROJECT_ID, etc.) +- Publishable client key (e.g. NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY, VITE_STACK_PUBLISHABLE_CLIENT_KEY, etc.) +- Secret server key: STACK_SECRET_SERVER_KEY (only for frameworks with server-side support, no prefix needed) ### 6) React only: Wrap the entire page in a Stack provider diff --git a/packages/stack-cli/tsdown.config.ts b/packages/stack-cli/tsdown.config.ts index a9b8bd3f94..db4ec2a8da 100644 --- a/packages/stack-cli/tsdown.config.ts +++ b/packages/stack-cli/tsdown.config.ts @@ -6,6 +6,7 @@ const config: UserConfig = { clean: false, dts: true, outDir: 'dist', + external: ['@anthropic-ai/claude-agent-sdk'], format: { esm: { outExtensions: () => ({ js: '.js', dts: '.d.ts' }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e282f59d9..c8627d2f71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -722,7 +722,7 @@ importers: version: 1.163.2(crossws@0.4.4(srvx@0.8.16)) nitro: specifier: ^3.0.0 - version: 3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)) + version: 3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)) react: specifier: 19.2.1 version: 19.2.1 @@ -1471,10 +1471,10 @@ importers: version: link:../../packages/stack '@supabase/ssr': specifier: latest - version: 0.9.0(@supabase/supabase-js@2.98.0) + version: 0.9.0(@supabase/supabase-js@2.99.0) '@supabase/supabase-js': specifier: latest - version: 2.98.0 + version: 2.99.0 jose: specifier: ^5.2.2 version: 5.6.3 @@ -1906,6 +1906,9 @@ importers: packages/stack-cli: dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.2.73 + version: 0.2.73(zod@4.1.12) '@inquirer/prompts': specifier: ^7.0.0 version: 7.10.1(@types/node@20.17.6) @@ -2430,6 +2433,12 @@ packages: '@antfu/utils@8.1.1': resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==} + '@anthropic-ai/claude-agent-sdk@0.2.73': + resolution: {integrity: sha512-JrHeMl93Q5ai9GMPAffQkSisbbDvD1skU2x6sf6WRzEZw0sK6aTG+XSiZHY2F5aSrfd4G2qUogLHEm6Y8obyOQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^4.0.0 + '@apidevtools/json-schema-ref-parser@11.9.3': resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} engines: {node: '>= 16'} @@ -8526,20 +8535,20 @@ packages: resolution: {integrity: sha512-SXuhqhuR5FXaYgKTXzZJeqtVA6JKb9IZWaGeEUxHHiOcFy2p51wccO72bYpXwoK4D5pzQOIYLTuAc7etxyMmwg==} engines: {node: '>=12.16'} - '@supabase/auth-js@2.98.0': - resolution: {integrity: sha512-GBH361T0peHU91AQNzOlIrjUZw9TZbB9YDRiyFgk/3Kvr3/Z1NWUZ2athWTfHhwNNi8IrW00foyFxQD9IO/Trg==} + '@supabase/auth-js@2.99.0': + resolution: {integrity: sha512-tHiIST/OEoLmWBE+3X69xRY5srJM/lL86KltmMlIfDo9ePJLo14vQQV9T4NF+P+MoGhCwQL1GTmk51zuAFMXKw==} engines: {node: '>=20.0.0'} - '@supabase/functions-js@2.98.0': - resolution: {integrity: sha512-N/xEyiNU5Org+d+PNCpv+TWniAXRzxIURxDYsS/m2I/sfAB/HcM9aM2Dmf5edj5oWb9GxID1OBaZ8NMmPXL+Lg==} + '@supabase/functions-js@2.99.0': + resolution: {integrity: sha512-zA9oad6EqGwMLLu2LfP1bXbqKcJGiotAdbdTfZG7YS7619YZQAEgejj9mp+E5vglKE1yMWbKK+S1J3PbuUtgLg==} engines: {node: '>=20.0.0'} - '@supabase/postgrest-js@2.98.0': - resolution: {integrity: sha512-v6e9WeZuJijzUut8HyXu6gMqWFepIbaeaMIm1uKzei4yLg9bC9OtEW9O14LE/9ezqNbSAnSLO5GtOLFdm7Bpkg==} + '@supabase/postgrest-js@2.99.0': + resolution: {integrity: sha512-8qfOMi2pu9y0IQhUAeFqjrvR49G4ELGevXCWV9qAHXFQ/h2FFh0I8PYjFQj4rHcHSq6hrpozDnS1vbQU8NAQ/A==} engines: {node: '>=20.0.0'} - '@supabase/realtime-js@2.98.0': - resolution: {integrity: sha512-rOWt28uGyFipWOSd+n0WVMr9kUXiWaa7J4hvyLCIHjRFqWm1z9CaaKAoYyfYMC1Exn3WT8WePCgiVhlAtWC2yw==} + '@supabase/realtime-js@2.99.0': + resolution: {integrity: sha512-7nFTZhNeANR7FvEY6PfWLCfE8dHqcaJd9SuR7IPEZvBPG9K4uEHMivpjZx4NWRSU7Eji7ZbKy2LG+cJ48DhwHg==} engines: {node: '>=20.0.0'} '@supabase/ssr@0.9.0': @@ -8547,12 +8556,12 @@ packages: peerDependencies: '@supabase/supabase-js': ^2.97.0 - '@supabase/storage-js@2.98.0': - resolution: {integrity: sha512-tzr2mG+v7ILSAZSfZMSL9OPyIH4z1ikgQ8EcQTKfMRz4EwmlFt3UnJaGzSOxyvF5b+fc9So7qdSUWTqGgeLokQ==} + '@supabase/storage-js@2.99.0': + resolution: {integrity: sha512-mAEEbfsght5EEALejYrwAP9k8sFBGjfMZT8n4SyMXk2iYuWVeRMs1kA/uKg0uDMctWdZ0bL+L4jZzksUJpCjMA==} engines: {node: '>=20.0.0'} - '@supabase/supabase-js@2.98.0': - resolution: {integrity: sha512-Ohc97CtInLwZyiSASz7tT9/Abm/vqnIbO9REp+PivVUII8UZsuI3bngRQnYgJdFoOIwvaEII1fX1qy8x0CyNiw==} + '@supabase/supabase-js@2.99.0': + resolution: {integrity: sha512-SP9Sn9tsHDB7N4u2gT13rdeZJewE4xibAxasG7vOz+fYi92+XkMMbWNx0uGK53zKTnAnvTs16isRooyBy4sn5w==} engines: {node: '>=20.0.0'} '@swc/counter@0.1.3': @@ -16957,6 +16966,20 @@ snapshots: '@antfu/utils@8.1.1': {} + '@anthropic-ai/claude-agent-sdk@0.2.73(zod@4.1.12)': + dependencies: + zod: 4.1.12 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.4 + '@img/sharp-darwin-x64': 0.34.4 + '@img/sharp-linux-arm': 0.34.4 + '@img/sharp-linux-arm64': 0.34.4 + '@img/sharp-linux-x64': 0.34.4 + '@img/sharp-linuxmusl-arm64': 0.34.4 + '@img/sharp-linuxmusl-x64': 0.34.4 + '@img/sharp-win32-arm64': 0.34.4 + '@img/sharp-win32-x64': 0.34.4 + '@apidevtools/json-schema-ref-parser@11.9.3': dependencies: '@jsdevtools/ono': 7.1.3 @@ -24890,19 +24913,19 @@ snapshots: '@stripe/stripe-js@7.7.0': {} - '@supabase/auth-js@2.98.0': + '@supabase/auth-js@2.99.0': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.98.0': + '@supabase/functions-js@2.99.0': dependencies: tslib: 2.8.1 - '@supabase/postgrest-js@2.98.0': + '@supabase/postgrest-js@2.99.0': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.98.0': + '@supabase/realtime-js@2.99.0': dependencies: '@types/phoenix': 1.6.6 '@types/ws': 8.18.1 @@ -24912,23 +24935,23 @@ snapshots: - bufferutil - utf-8-validate - '@supabase/ssr@0.9.0(@supabase/supabase-js@2.98.0)': + '@supabase/ssr@0.9.0(@supabase/supabase-js@2.99.0)': dependencies: - '@supabase/supabase-js': 2.98.0 + '@supabase/supabase-js': 2.99.0 cookie: 1.0.2 - '@supabase/storage-js@2.98.0': + '@supabase/storage-js@2.99.0': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.98.0': + '@supabase/supabase-js@2.99.0': dependencies: - '@supabase/auth-js': 2.98.0 - '@supabase/functions-js': 2.98.0 - '@supabase/postgrest-js': 2.98.0 - '@supabase/realtime-js': 2.98.0 - '@supabase/storage-js': 2.98.0 + '@supabase/auth-js': 2.99.0 + '@supabase/functions-js': 2.99.0 + '@supabase/postgrest-js': 2.99.0 + '@supabase/realtime-js': 2.99.0 + '@supabase/storage-js': 2.99.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -28361,7 +28384,7 @@ snapshots: debug: 4.4.3 enhanced-resolve: 5.17.1 eslint: 8.57.1 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) fast-glob: 3.3.3 get-tsconfig: 4.8.1 is-bun-module: 1.2.1 @@ -28404,7 +28427,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): + eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: @@ -28482,7 +28505,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.56.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -31685,7 +31708,7 @@ snapshots: nice-try@1.0.5: {} - nitro@3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(rolldown@1.0.0-rc.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)): + nitro@3.0.0(@electric-sql/pglite@0.3.2)(chokidar@4.0.3)(lru-cache@11.2.2)(mysql2@3.15.3)(vite@7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0)): dependencies: consola: 3.4.2 cookie-es: 2.0.0 @@ -31705,7 +31728,6 @@ snapshots: unenv: 2.0.0-rc.21 unstorage: 2.0.0-alpha.3(chokidar@4.0.3)(db0@0.3.4(@electric-sql/pglite@0.3.2)(mysql2@3.15.3))(lru-cache@11.2.2)(ofetch@1.5.1) optionalDependencies: - rolldown: 1.0.0-rc.3 vite: 7.3.1(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.0) transitivePeerDependencies: - '@azure/app-configuration' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d9d37a2fcc..956387e6d6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,7 @@ packages: minimumReleaseAge: 2880 minimumReleaseAgeExclude: +- '@anthropic-ai/claude-agent-sdk' - ai - '@ai-sdk/openai' - '@ai-sdk/react' From 1edd51c4d7681fe164e4dd87d20d128a24d8e634 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 12 Mar 2026 13:03:48 -0700 Subject: [PATCH 4/5] 400 on invalid json --- .../api/latest/integrations/ai-proxy/[[...path]]/route.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts b/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts index 1aa9deb751..c6fd1a4691 100644 --- a/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts +++ b/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts @@ -16,7 +16,12 @@ function getApiKey(): string { function sanitizeBody(raw: ArrayBuffer): Uint8Array { const text = new TextDecoder().decode(raw); - const parsed = JSON.parse(text); + let parsed; + try { + parsed = JSON.parse(text); + } catch { + throw new StatusError(400, "Request body must be valid JSON"); + } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { throw new StatusError(400, "Request body must be a JSON object"); From 667b0ff2e35f01138ca095979f612972424eca3e Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 13 Mar 2026 09:34:49 -0700 Subject: [PATCH 5/5] pr comment fixes --- .../latest/integrations/ai-proxy/[[...path]]/route.ts | 10 +--------- packages/stack-cli/src/commands/init.ts | 2 +- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts b/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts index c6fd1a4691..5071b91671 100644 --- a/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts +++ b/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts @@ -6,14 +6,6 @@ import { NextRequest } from "next/server"; const OPENROUTER_BASE_URL = "https://openrouter.ai/api"; const OPENROUTER_MODEL = "anthropic/claude-sonnet-4.6"; -function getApiKey(): string { - const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY", ""); - if (!apiKey) { - throw new StatusError(503, "AI proxy not configured"); - } - return apiKey; -} - function sanitizeBody(raw: ArrayBuffer): Uint8Array { const text = new TextDecoder().decode(raw); let parsed; @@ -38,7 +30,7 @@ function sanitizeBody(raw: ArrayBuffer): Uint8Array { } async function proxyToOpenRouter(req: NextRequest, options: { params: Promise<{ path?: string[] }> }) { - const apiKey = getApiKey(); + const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY"); const params = await options.params; const subpath = params.path?.join("/") ?? ""; const targetUrl = `${OPENROUTER_BASE_URL}/${subpath}${req.nextUrl.search}`; diff --git a/packages/stack-cli/src/commands/init.ts b/packages/stack-cli/src/commands/init.ts index fc12b3158a..67ceff3d2f 100644 --- a/packages/stack-cli/src/commands/init.ts +++ b/packages/stack-cli/src/commands/init.ts @@ -180,7 +180,7 @@ async function handleLinkFromCloud(flags: Record, opts: InitOpt const project = projects.find((p) => p.id === projectId)!; const apiKey = await project.app.createInternalApiKey({ - description: "Created by stack init", + description: "Created by CLI init script", expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 200), // 200 years hasPublishableClientKey: true, hasSecretServerKey: true,