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
251 changes: 186 additions & 65 deletions packages/stack-cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,7 +28,7 @@ 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("--mode <mode>", "Mode: create, create-cloud, link-config, or link-cloud (skips interactive prompts)")
Comment thread
BilalG1 marked this conversation as resolved.
.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)")
Expand All @@ -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<NonNullable<InitOptions["mode"]>, Array<keyof InitOptions>> = {
"create": ["selectProjectId", "configFile"],
"create-cloud": ["selectProjectId", "configFile", "apps"],
"link-config": ["selectProjectId", "apps"],
"link-cloud": ["configFile", "apps"],
};
const flagNames: Partial<Record<keyof InitOptions, string>> = {
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";
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
Expand All @@ -96,23 +160,21 @@ async function runInit(program: Command, opts: InitOptions) {
}
}

async function handleLink(flags: Record<string, unknown>, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> {
async function handleLink(flags: Record<string, unknown>, 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") {
Expand Down Expand Up @@ -142,48 +204,26 @@ async function handleLinkFromConfigFile(opts: InitOptions): Promise<{ configPath
return { configPath };
}

async function handleLinkFromCloud(flags: Record<string, unknown>, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> {
let sessionAuth;
async function ensureLoggedInSession(flags: Record<string, unknown>) {
try {
sessionAuth = resolveSessionAuth(flags as { projectId?: string });
return 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;
return resolveSessionAuth(flags as { projectId?: string });
}
throw e;
}
}
Comment thread
BilalG1 marked this conversation as resolved.

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
Expand All @@ -192,11 +232,14 @@ async function handleLinkFromCloud(flags: Record<string, unknown>, 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");
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const envPath = path.resolve(outputDir, ".env");
Expand Down Expand Up @@ -226,7 +269,70 @@ async function handleLinkFromCloud(flags: Record<string, unknown>, opts: InitOpt
fs.writeFileSync(envPath, envLines + "\n");
Comment thread
BilalG1 marked this conversation as resolved.
console.log("\nCreated .env with Stack Auth keys");
}
}

async function handleCreateCloud(flags: Record<string, unknown>, 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<string, unknown>, 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 <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);
Comment thread
BilalG1 marked this conversation as resolved.
return {};
}

Comment thread
BilalG1 marked this conversation as resolved.
Expand Down Expand Up @@ -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}`);
Expand Down
38 changes: 3 additions & 35 deletions packages/stack-cli/src/commands/project.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading