@@ -10,11 +10,13 @@ import { writeConfigValue } from "../lib/config.js";
1010import { CliError , AuthError } from "../lib/errors.js" ;
1111import { isNonInteractiveEnv } from "../lib/interactive.js" ;
1212import { createInitPrompt } from "../lib/init-prompt.js" ;
13+ import { createProjectInteractively } from "../lib/create-project.js" ;
1314import { runClaudeAgent } from "../lib/claude-agent.js" ;
1415import { detectImportPackageFromDir , renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering" ;
16+ import { throwErr } from "@stackframe/stack-shared/dist/utils/errors" ;
1517
1618type 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+
5482async 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 } ` ) ;
0 commit comments