Skip to content

Commit 3b8667d

Browse files
BilalG1aadesh18
andauthored
cli add back init options (#1379)
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a "create-cloud" mode to the CLI init flow. * New interactive project creation flow that can prompt for display name and select/create a team-backed project. * **Behavior Changes** * Init now resolves mode from flags, config, or interactive prompts; prompts to choose linking vs creating when inputs are missing. * Non-interactive runs now error when required inputs are absent; cloud linking offers auto-create in interactive mode. * **Refactor** * Centralized auth, project-creation, and env key writing for clearer, safer linking and creation flows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: aadesh18 <110230993+aadesh18@users.noreply.github.com>
1 parent 04d57d9 commit 3b8667d

3 files changed

Lines changed: 225 additions & 100 deletions

File tree

packages/stack-cli/src/commands/init.ts

Lines changed: 186 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import { writeConfigValue } from "../lib/config.js";
1010
import { CliError, AuthError } from "../lib/errors.js";
1111
import { isNonInteractiveEnv } from "../lib/interactive.js";
1212
import { createInitPrompt } from "../lib/init-prompt.js";
13+
import { createProjectInteractively } from "../lib/create-project.js";
1314
import { runClaudeAgent } from "../lib/claude-agent.js";
1415
import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering";
16+
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
1517

1618
type InitOptions = {
17-
mode?: "create" | "link-config" | "link-cloud",
19+
mode?: "create" | "create-cloud" | "link-config" | "link-cloud",
1820
apps?: string,
1921
configFile?: string,
2022
selectProjectId?: string,
@@ -26,7 +28,7 @@ export function registerInitCommand(program: Command) {
2628
program
2729
.command("init")
2830
.description("Initialize Stack Auth in your project")
29-
.option("--mode <mode>", "Mode: create, link-config, or link-cloud (skips interactive prompts)")
31+
.option("--mode <mode>", "Mode: create, create-cloud, link-config, or link-cloud (skips interactive prompts)")
3032
.option("--apps <apps>", "Comma-separated app IDs to enable (for create mode)")
3133
.option("--config-file <path>", "Path to existing config file (for link-config mode)")
3234
.option("--select-project-id <id>", "Project ID to link (for link-cloud mode)")
@@ -51,32 +53,94 @@ export function registerInitCommand(program: Command) {
5153
});
5254
}
5355

56+
function validateOptions(opts: InitOptions) {
57+
if (opts.selectProjectId && opts.configFile) {
58+
throw new CliError("--select-project-id and --config-file cannot be used together.");
59+
}
60+
61+
const incompatible: Record<NonNullable<InitOptions["mode"]>, Array<keyof InitOptions>> = {
62+
"create": ["selectProjectId", "configFile"],
63+
"create-cloud": ["selectProjectId", "configFile", "apps"],
64+
"link-config": ["selectProjectId", "apps"],
65+
"link-cloud": ["configFile", "apps"],
66+
};
67+
const flagNames: Partial<Record<keyof InitOptions, string>> = {
68+
selectProjectId: "--select-project-id",
69+
configFile: "--config-file",
70+
apps: "--apps",
71+
};
72+
73+
if (opts.mode) {
74+
for (const key of incompatible[opts.mode]) {
75+
if (opts[key] != null) {
76+
throw new CliError(`${flagNames[key]} cannot be used with --mode ${opts.mode}.`);
77+
}
78+
}
79+
}
80+
}
81+
5482
async function runInit(program: Command, opts: InitOptions) {
5583
const flags = program.opts();
5684
const outputDir = opts.outputDir ? path.resolve(opts.outputDir) : process.cwd();
5785

86+
if (!fs.existsSync(outputDir)) {
87+
throw new CliError(`Output directory does not exist: ${outputDir}`);
88+
}
89+
90+
validateOptions(opts);
91+
5892
console.log("Welcome to Stack Auth!\n");
5993

60-
const mode: string = "link";
61-
// TODO: re-enable local emulator option
62-
// const mode: string = opts.mode ?? await select({
63-
// message: "Would you like to link to an existing project, or create a new one?",
64-
// choices: [
65-
// { name: "Create a new project (local emulator)", value: "create" as const },
66-
// { name: "Link an existing project", value: "link" as const },
67-
// ],
68-
// });
94+
let mode: "create" | "create-cloud" | "link" | "link-config" | "link-cloud";
95+
if (opts.mode) {
96+
mode = opts.mode;
97+
} else if (opts.selectProjectId) {
98+
mode = "link-cloud";
99+
} else if (opts.configFile) {
100+
mode = "link-config";
101+
} else {
102+
const action = await select({
103+
message: "Would you like to link to an existing project, or create a new one?",
104+
choices: [
105+
{ name: "Create a new project", value: "create" as const },
106+
{ name: "Link an existing project", value: "link" as const },
107+
],
108+
});
109+
110+
if (action === "link") {
111+
mode = "link";
112+
} else {
113+
const location = await select({
114+
message: "Where would you like to create the project?",
115+
choices: [
116+
{ name: "Stack Auth Cloud", value: "hosted" as const },
117+
{ name: "Local (requires local emulator installation, ~1.3gb storage required)", value: "local" as const },
118+
],
119+
});
120+
mode = location === "local" ? "create" : "create-cloud";
121+
}
122+
}
69123

70124
let configPath: string | undefined;
71125

72-
if (mode === "link" || mode === "link-config" || mode === "link-cloud") {
73-
const result = await handleLink(flags, opts, outputDir);
74-
configPath = result.configPath;
75-
} else if (mode === "create") {
76-
const result = await handleCreate(opts, outputDir);
77-
configPath = result.configPath;
78-
} else {
79-
throw new CliError(`Unknown mode: ${mode}`);
126+
switch (mode) {
127+
case "link":
128+
case "link-config":
129+
case "link-cloud": {
130+
const result = await handleLink(flags, opts, outputDir, mode);
131+
configPath = result.configPath;
132+
break;
133+
}
134+
case "create": {
135+
const result = await handleCreate(opts, outputDir);
136+
configPath = result.configPath;
137+
break;
138+
}
139+
case "create-cloud": {
140+
const result = await handleCreateCloud(flags, opts, outputDir);
141+
configPath = result.configPath;
142+
break;
143+
}
80144
}
81145

82146
const initPrompt = createInitPrompt(false, configPath);
@@ -96,23 +160,21 @@ async function runInit(program: Command, opts: InitOptions) {
96160
}
97161
}
98162

99-
async function handleLink(flags: Record<string, unknown>, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> {
163+
async function handleLink(flags: Record<string, unknown>, opts: InitOptions, outputDir: string, resolvedMode: "link" | "link-config" | "link-cloud"): Promise<{ configPath?: string }> {
100164
let source: "config-file" | "cloud";
101165

102-
if (opts.mode === "link-config") {
166+
if (resolvedMode === "link-config") {
103167
source = "config-file";
104-
} else if (opts.mode === "link-cloud") {
168+
} else if (resolvedMode === "link-cloud") {
105169
source = "cloud";
106170
} else {
107-
source = "cloud";
108-
// TODO: re-enable config file linking option
109-
// source = await select({
110-
// message: "How would you like to link your project?",
111-
// choices: [
112-
// { name: "Link from config file", value: "config-file" as const },
113-
// { name: "Link from app.stack-auth.com", value: "cloud" as const },
114-
// ],
115-
// });
171+
source = await select({
172+
message: "How would you like to link your project?",
173+
choices: [
174+
{ name: "Link from config file", value: "config-file" as const },
175+
{ name: "Link from app.stack-auth.com", value: "cloud" as const },
176+
],
177+
});
116178
}
117179

118180
if (source === "config-file") {
@@ -142,48 +204,26 @@ async function handleLinkFromConfigFile(opts: InitOptions): Promise<{ configPath
142204
return { configPath };
143205
}
144206

145-
async function handleLinkFromCloud(flags: Record<string, unknown>, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> {
146-
let sessionAuth;
207+
async function ensureLoggedInSession(flags: Record<string, unknown>) {
147208
try {
148-
sessionAuth = resolveSessionAuth(flags as { projectId?: string });
209+
return resolveSessionAuth(flags as { projectId?: string });
149210
} catch (e) {
150211
if (e instanceof AuthError) {
151212
if (isNonInteractiveEnv()) {
152213
throw new CliError("Not logged in. Run `stack login` first or set STACK_CLI_REFRESH_TOKEN.");
153214
}
154215
console.log("You need to log in first.\n");
155216
await performLogin(flags);
156-
sessionAuth = resolveSessionAuth(flags as { projectId?: string });
157-
} else {
158-
throw e;
217+
return resolveSessionAuth(flags as { projectId?: string });
159218
}
219+
throw e;
160220
}
221+
}
161222

162-
const user = await getInternalUser(sessionAuth);
163-
const projects = await user.listOwnedProjects();
164-
165-
if (projects.length === 0) {
166-
throw new CliError("You don't own any projects. Create one at app.stack-auth.com first.");
167-
}
168-
169-
let projectId: string;
170-
if (opts.selectProjectId) {
171-
const found = projects.find((p) => p.id === opts.selectProjectId);
172-
if (!found) {
173-
throw new CliError(`Project '${opts.selectProjectId}' not found among your owned projects.`);
174-
}
175-
projectId = opts.selectProjectId;
176-
} else {
177-
projectId = await select({
178-
message: "Select a project:",
179-
choices: projects.map((p) => ({
180-
name: `${p.displayName} (${p.id})`,
181-
value: p.id,
182-
})),
183-
});
184-
}
185-
186-
const project = projects.find((p) => p.id === projectId)!;
223+
async function writeProjectKeysToEnv(
224+
project: { id: string, app: { createInternalApiKey: (opts: { description: string, expiresAt: Date, hasPublishableClientKey: boolean, hasSecretServerKey: boolean, hasSuperSecretAdminKey: boolean }) => Promise<{ publishableClientKey?: string | null, secretServerKey?: string | null }> } },
225+
outputDir: string,
226+
) {
187227
const apiKey = await project.app.createInternalApiKey({
188228
description: "Created by CLI init script",
189229
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 200), // 200 years
@@ -192,11 +232,14 @@ async function handleLinkFromCloud(flags: Record<string, unknown>, opts: InitOpt
192232
hasSuperSecretAdminKey: false,
193233
});
194234

235+
const publishableClientKey = apiKey.publishableClientKey ?? throwErr("createInternalApiKey returned no publishableClientKey despite hasPublishableClientKey=true");
236+
const secretServerKey = apiKey.secretServerKey ?? throwErr("createInternalApiKey returned no secretServerKey despite hasSecretServerKey=true");
237+
195238
const envLines = [
196239
"# Stack Auth",
197-
`NEXT_PUBLIC_STACK_PROJECT_ID=${projectId}`,
198-
`NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${apiKey.publishableClientKey ?? ""}`,
199-
`STACK_SECRET_SERVER_KEY=${apiKey.secretServerKey ?? ""}`,
240+
`NEXT_PUBLIC_STACK_PROJECT_ID=${project.id}`,
241+
`NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${publishableClientKey}`,
242+
`STACK_SECRET_SERVER_KEY=${secretServerKey}`,
200243
].join("\n");
201244

202245
const envPath = path.resolve(outputDir, ".env");
@@ -226,7 +269,70 @@ async function handleLinkFromCloud(flags: Record<string, unknown>, opts: InitOpt
226269
fs.writeFileSync(envPath, envLines + "\n");
227270
console.log("\nCreated .env with Stack Auth keys");
228271
}
272+
}
273+
274+
async function handleCreateCloud(flags: Record<string, unknown>, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> {
275+
const sessionAuth = await ensureLoggedInSession(flags);
276+
const user = await getInternalUser(sessionAuth);
277+
278+
const newProject = await createProjectInteractively(user, {
279+
defaultDisplayName: path.basename(outputDir),
280+
});
281+
console.log(`\nCreated project: ${newProject.displayName} (${newProject.id})\n`);
282+
283+
await writeProjectKeysToEnv(newProject, outputDir);
284+
return {};
285+
}
286+
287+
async function handleLinkFromCloud(flags: Record<string, unknown>, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> {
288+
const sessionAuth = await ensureLoggedInSession(flags);
289+
const user = await getInternalUser(sessionAuth);
290+
let projects = await user.listOwnedProjects();
291+
let autoCreatedProjectId: string | null = null;
292+
293+
if (projects.length === 0) {
294+
if (isNonInteractiveEnv()) {
295+
throw new CliError("No projects found. Run `stack project create --display-name <name>` first, or set --select-project-id.");
296+
}
297+
298+
const shouldCreate = await confirm({
299+
message: "You don't have any Stack Auth projects yet. Would you like to create one?",
300+
default: true,
301+
});
302+
303+
if (!shouldCreate) {
304+
throw new CliError("You don't own any projects. Create one at app.stack-auth.com or re-run and choose to create one.");
305+
}
306+
307+
const newProject = await createProjectInteractively(user, {
308+
defaultDisplayName: path.basename(outputDir),
309+
});
310+
console.log(`\nCreated project: ${newProject.displayName} (${newProject.id})\n`);
311+
projects = [newProject];
312+
autoCreatedProjectId = newProject.id;
313+
}
229314

315+
let projectId: string;
316+
if (opts.selectProjectId) {
317+
const found = projects.find((p) => p.id === opts.selectProjectId);
318+
if (!found) {
319+
throw new CliError(`Project '${opts.selectProjectId}' not found among your owned projects.`);
320+
}
321+
projectId = opts.selectProjectId;
322+
} else if (autoCreatedProjectId) {
323+
projectId = autoCreatedProjectId;
324+
} else {
325+
projectId = await select({
326+
message: "Select a project:",
327+
choices: projects.map((p) => ({
328+
name: `${p.displayName} (${p.id})`,
329+
value: p.id,
330+
})),
331+
});
332+
}
333+
334+
const project = projects.find((p) => p.id === projectId)!;
335+
await writeProjectKeysToEnv(project, outputDir);
230336
return {};
231337
}
232338

@@ -298,6 +404,21 @@ async function handleCreate(opts: InitOptions, outputDir: string): Promise<{ con
298404
const importPackage = detectImportPackageFromDir(path.dirname(configPath));
299405
const content = renderConfigFileContent(config, importPackage);
300406
fs.mkdirSync(path.dirname(configPath), { recursive: true });
407+
408+
if (fs.existsSync(configPath)) {
409+
if (isNonInteractiveEnv()) {
410+
throw new CliError(`Config file already exists at ${configPath}. Refusing to overwrite in non-interactive mode.`);
411+
}
412+
const shouldOverwrite = await confirm({
413+
message: `Config file already exists at ${configPath}. Overwrite?`,
414+
default: false,
415+
});
416+
if (!shouldOverwrite) {
417+
console.log("\nLeaving existing config file unchanged.");
418+
return { configPath };
419+
}
420+
}
421+
301422
fs.writeFileSync(configPath, content);
302423

303424
console.log(`\nConfig file written to ${configPath}`);

packages/stack-cli/src/commands/project.ts

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,7 @@
11
import { Command } from "commander";
2-
import * as readline from "readline";
32
import { resolveSessionAuth } from "../lib/auth.js";
43
import { getInternalUser } from "../lib/app.js";
5-
import { isNonInteractiveEnv } from "../lib/interactive.js";
6-
import { CliError } from "../lib/errors.js";
7-
8-
function prompt(question: string): Promise<string> {
9-
const rl = readline.createInterface({
10-
input: process.stdin,
11-
output: process.stdout,
12-
});
13-
return new Promise((resolve) => {
14-
rl.question(question, (answer) => {
15-
rl.close();
16-
resolve(answer);
17-
});
18-
});
19-
}
4+
import { createProjectInteractively } from "../lib/create-project.js";
205

216
export function registerProjectCommand(program: Command) {
227
const project = program
@@ -54,25 +39,8 @@ export function registerProjectCommand(program: Command) {
5439
const auth = resolveSessionAuth(flags);
5540
const user = await getInternalUser(auth);
5641

57-
let displayName: string = opts.displayName;
58-
if (!displayName) {
59-
if (isNonInteractiveEnv()) {
60-
throw new CliError("--display-name is required in non-interactive environments (CI).");
61-
}
62-
displayName = await prompt("Project display name: ");
63-
if (!displayName.trim()) {
64-
throw new CliError("Display name cannot be empty.");
65-
}
66-
}
67-
68-
const teams = await user.listTeams();
69-
if (teams.length === 0) {
70-
throw new CliError("No teams found. You need a team to create a project.");
71-
}
72-
73-
const newProject = await user.createProject({
74-
displayName,
75-
teamId: teams[0].id,
42+
const newProject = await createProjectInteractively(user, {
43+
displayName: opts.displayName,
7644
});
7745

7846
if (program.opts().json) {

0 commit comments

Comments
 (0)