Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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 sanitizeBody(raw: ArrayBuffer): Uint8Array {
const text = new TextDecoder().decode(raw);
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");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
}
Comment thread
BilalG1 marked this conversation as resolved.

return new TextEncoder().encode(JSON.stringify(parsed));
}

async function proxyToOpenRouter(req: NextRequest, options: { params: Promise<{ path?: string[] }> }) {
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}`;
Comment thread
BilalG1 marked this conversation as resolved.

const headers: Record<string, string> = {
"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",
},
});
}
Comment thread
BilalG1 marked this conversation as resolved.

export const GET = handleApiRequest(proxyToOpenRouter);
export const POST = handleApiRequest(proxyToOpenRouter);
Comment thread
BilalG1 marked this conversation as resolved.
115 changes: 115 additions & 0 deletions apps/e2e/tests/general/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
3 changes: 3 additions & 0 deletions packages/stack-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@
"author": "",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.73",
"@inquirer/prompts": "^7.0.0",
"@stackframe/js": "workspace:*",
"@stackframe/stack-shared": "workspace:*",
Comment thread
BilalG1 marked this conversation as resolved.
"commander": "^13.1.0",
"jiti": "^2.4.2"
},
Expand Down
Loading
Loading