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
138 changes: 97 additions & 41 deletions packages/stack-cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ 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";

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 +27,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)")
.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 Down Expand Up @@ -57,13 +58,24 @@ async function runInit(program: Command, opts: InitOptions) {

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: "Link an existing project", value: "link" as const },
{ name: "Create a new project (local emulator)", value: "create" as const },
],
});
let mode: string;
if (opts.mode) {
mode = opts.mode;
} else if (opts.selectProjectId) {
mode = "link-cloud";
} else if (opts.configFile) {
mode = "link-config";
} else {
console.log("Creating a new Stack Auth project.\n");
const location = await select({
message: "Where would you like to create the project?",
choices: [
{ name: "Stack Auth Cloud", value: "hosted" as const },
{ name: "Local (requires local emulator installation, ~1.3gb storage required)", value: "local" as const },
],
});
mode = location === "local" ? "create" : "create-cloud";
}

let configPath: string | undefined;

Expand All @@ -73,6 +85,9 @@ async function runInit(program: Command, opts: InitOptions) {
} else if (mode === "create") {
const result = await handleCreate(opts, outputDir);
configPath = result.configPath;
} else if (mode === "create-cloud") {
const result = await handleCreateCloud(flags, opts, outputDir);
configPath = result.configPath;
} else {
throw new CliError(`Unknown mode: ${mode}`);
}
Expand Down Expand Up @@ -138,48 +153,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;
}
}

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 @@ -190,7 +183,7 @@ async function handleLinkFromCloud(flags: Record<string, unknown>, opts: InitOpt

const envLines = [
"# Stack Auth",
`NEXT_PUBLIC_STACK_PROJECT_ID=${projectId}`,
`NEXT_PUBLIC_STACK_PROJECT_ID=${project.id}`,
`NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${apiKey.publishableClientKey ?? ""}`,
`STACK_SECRET_SERVER_KEY=${apiKey.secretServerKey ?? ""}`,
].join("\n");
Expand Down Expand Up @@ -222,7 +215,70 @@ async function handleLinkFromCloud(flags: Record<string, unknown>, opts: InitOpt
fs.writeFileSync(envPath, envLines + "\n");
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;
Comment thread
BilalG1 marked this conversation as resolved.
Comment thread
BilalG1 marked this conversation as resolved.
} else {
projectId = await select({
message: "Select a project:",
choices: projects.map((p) => ({
name: `${p.displayName} (${p.id})`,
value: p.id,
})),
});
}

const project = projects.find((p) => p.id === projectId)!;
await writeProjectKeysToEnv(project, outputDir);
return {};
}

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
36 changes: 36 additions & 0 deletions packages/stack-cli/src/lib/create-project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { input } from "@inquirer/prompts";
import type { CurrentInternalUser } from "@stackframe/js";
import { CliError } from "./errors.js";
import { isNonInteractiveEnv } from "./interactive.js";

type CreateProjectOptions = {
displayName?: string,
defaultDisplayName?: string,
};

export async function createProjectInteractively(
user: CurrentInternalUser,
opts: CreateProjectOptions = {},
) {
let displayName = opts.displayName;
Comment thread
BilalG1 marked this conversation as resolved.
if (!displayName) {
if (isNonInteractiveEnv()) {
throw new CliError("--display-name is required in non-interactive environments (CI).");
}
displayName = await input({
message: "Project display name:",
default: opts.defaultDisplayName,
validate: (v) => v.trim().length > 0 || "Display name cannot be empty.",
});
}
Comment thread
BilalG1 marked this conversation as resolved.

const teams = await user.listTeams();
if (teams.length === 0) {
throw new CliError("No teams found on your account. Create a team at app.stack-auth.com first.");
}

return await user.createProject({
displayName: displayName.trim(),
teamId: teams[0].id,
Comment thread
BilalG1 marked this conversation as resolved.
});
}
Loading