mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-16 21:08:38 +08:00
## Summary - add a public `defineStackConfig` helper and `StackConfig` type for nested config authoring - emit helper-based nested config files from the CLI and local emulator - update type coverage and e2e expectations for the new `stack.config` format ## Testing - pnpm --filter ./packages/stack-shared typecheck - pnpm --filter ./packages/stack-cli typecheck - pnpm --filter ./apps/backend typecheck - pnpm --filter ./apps/e2e typecheck <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Type-safe configuration API with compile-time validation * New config rendering utility for producing typed config files * Public local-emulator settings and a public helper to detect emulator mode * Added --overwrite flag for config pull * **Improvements** * Stronger validation and clearer errors for invalid or conflicting config shapes * Config output now includes explicit TypeScript typing * **Tests** * Added and strengthened tests for config authoring, rendering, CLI behavior, and emulator flows <!-- end of auto-generated comment: release notes by coderabbit.ai -->
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
import { Command } from "commander";
|
|
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";
|
|
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",
|
|
apps?: string,
|
|
configFile?: string,
|
|
selectProjectId?: string,
|
|
outputDir?: string,
|
|
agent?: boolean,
|
|
};
|
|
|
|
export function registerInitCommand(program: Command) {
|
|
program
|
|
.command("init")
|
|
.description("Initialize Stack Auth in your project")
|
|
.option("--mode <mode>", "Mode: create, link-config, or link-cloud (skips interactive prompts)")
|
|
.option("--apps <apps>", "Comma-separated app IDs to enable (for create mode)")
|
|
.option("--config-file <path>", "Path to existing config file (for link-config mode)")
|
|
.option("--select-project-id <id>", "Project ID to link (for link-cloud mode)")
|
|
.option("--output-dir <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;
|
|
|
|
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 = "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 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}`);
|
|
}
|
|
|
|
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<string, unknown>, 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 = "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 },
|
|
// ],
|
|
// });
|
|
}
|
|
|
|
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<string, unknown>, 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 CLI init script",
|
|
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)) {
|
|
const existing = fs.readFileSync(envPath, "utf-8");
|
|
const separator = existing.endsWith("\n") ? "\n" : "\n\n";
|
|
|
|
if (isNonInteractiveEnv()) {
|
|
fs.appendFileSync(envPath, separator + 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, separator + 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<string, unknown>) {
|
|
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);
|
|
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)
|
|
.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 = Object.fromEntries(
|
|
selectedApps.map((appId) => [appId, { enabled: true }])
|
|
);
|
|
|
|
const config = {
|
|
apps: {
|
|
installed,
|
|
},
|
|
};
|
|
|
|
const importPackage = detectImportPackageFromDir(path.dirname(configPath));
|
|
const content = renderConfigFileContent(config, importPackage);
|
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
fs.writeFileSync(configPath, content);
|
|
|
|
console.log(`\nConfig file written to ${configPath}`);
|
|
return { configPath };
|
|
}
|