@@ -27,20 +27,16 @@ export async function createApp(ctx: {
2727 const template = ctx . template ?? 'reactRouter'
2828 const packageManager =
2929 ctx . packageManager ?? ( process . env . E2E_PACKAGE_MANAGER as 'npm' | 'yarn' | 'pnpm' | 'bun' ) ?? 'pnpm'
30-
31- const args = [
32- '--name' ,
33- name ,
34- '--path' ,
35- parentDir ,
36- '--package-manager' ,
37- packageManager ,
38- '--local' ,
39- '--template' ,
40- template ,
41- ]
30+ // reactRouter/remix both require a --flavor or they'll hang on the language
31+ // prompt in non-interactive runs. Default to javascript when template needs
32+ // it. For `--template none` (extension-only) flavor is ignored.
33+ const flavor = ctx . flavor ?? ( template === 'none' ? undefined : 'javascript' )
34+
35+ const args = [ '--template' , template ]
36+ if ( flavor ) args . push ( '--flavor' , flavor )
37+ args . push ( '--name' , name , '--package-manager' , packageManager , '--local' )
4238 if ( ctx . orgId ) args . push ( '--organization-id' , ctx . orgId )
43- if ( ctx . flavor ) args . push ( '--flavor ' , ctx . flavor )
39+ args . push ( '--path ' , parentDir )
4440
4541 const result = await cli . execCreateApp ( args , {
4642 env : { FORCE_COLOR : '0' } ,
@@ -116,8 +112,9 @@ export async function generateExtension(
116112 flavor ?: string
117113 } ,
118114) : Promise < ExecResult > {
119- const args = [ 'app' , 'generate' , 'extension' , '--name' , ctx . name , '--path' , ctx . appDir , '-- template', ctx . template ]
115+ const args = [ 'app' , 'generate' , 'extension' , '--template' , ctx . template ]
120116 if ( ctx . flavor ) args . push ( '--flavor' , ctx . flavor )
117+ args . push ( '--name' , ctx . name , '--path' , ctx . appDir )
121118 return ctx . cli . exec ( args , { timeout : CLI_TIMEOUT . long } )
122119}
123120
@@ -134,12 +131,13 @@ export async function deployApp(
134131 noBuild ?: boolean
135132 } ,
136133) : Promise < ExecResult > {
137- const args = [ 'app' , 'deploy' , '--path' , ctx . appDir ]
138- if ( ctx . force ?? true ) args . push ( '--force' )
139- if ( ctx . noBuild ) args . push ( '--no-build' )
134+ const args = [ 'app' , 'deploy' ]
140135 if ( ctx . version ) args . push ( '--version' , ctx . version )
141136 if ( ctx . message ) args . push ( '--message' , ctx . message )
142137 if ( ctx . config ) args . push ( '--config' , ctx . config )
138+ if ( ctx . force ?? true ) args . push ( '--force' )
139+ if ( ctx . noBuild ) args . push ( '--no-build' )
140+ args . push ( '--path' , ctx . appDir )
143141 return ctx . cli . exec ( args , { timeout : CLI_TIMEOUT . long } )
144142}
145143
@@ -152,7 +150,7 @@ export async function appInfo(ctx: CLIContext): Promise<{
152150 entrySourceFilePath : string
153151 } [ ]
154152} > {
155- const result = await ctx . cli . exec ( [ 'app' , 'info' , '--path ' , ctx . appDir , '--json' ] )
153+ const result = await ctx . cli . exec ( [ 'app' , 'info' , '--json ' , '--path' , ctx . appDir ] )
156154 if ( result . exitCode !== 0 ) {
157155 throw new Error ( `app info failed (exit ${ result . exitCode } ):\nstdout: ${ result . stdout } \nstderr: ${ result . stderr } ` )
158156 }
@@ -168,25 +166,124 @@ export async function functionRun(
168166 inputPath : string
169167 } ,
170168) : Promise < ExecResult > {
171- return ctx . cli . exec ( [ 'app' , 'function' , 'run' , '--path ' , ctx . appDir , '--input ' , ctx . inputPath ] , {
169+ return ctx . cli . exec ( [ 'app' , 'function' , 'run' , '--input ' , ctx . inputPath , '--path ' , ctx . appDir ] , {
172170 timeout : CLI_TIMEOUT . short ,
173171 } )
174172}
175173
176- export async function versionsList ( ctx : CLIContext ) : Promise < ExecResult > {
177- return ctx . cli . exec ( [ 'app' , 'versions' , 'list' , '--path' , ctx . appDir , '--json' ] , {
178- timeout : CLI_TIMEOUT . short ,
179- } )
174+ export async function versionsList (
175+ ctx : CLIContext & {
176+ config ?: string
177+ } ,
178+ ) : Promise < ExecResult > {
179+ const args = [ 'app' , 'versions' , 'list' , '--json' ]
180+ if ( ctx . config ) args . push ( '--config' , ctx . config )
181+ args . push ( '--path' , ctx . appDir )
182+ return ctx . cli . exec ( args , { timeout : CLI_TIMEOUT . short } )
180183}
181184
185+ /**
186+ * Run `app config link` to create a brand-new app on Shopify interactively.
187+ * Answers the prompts:
188+ * "Which organization is this work for?" → filter by orgId → Enter
189+ * "Create this project as a new app on Shopify?" → Yes (default)
190+ * "App name" → appName
191+ * "Configuration file name" → skipped via `--config` flag
192+ *
193+ * Env overrides (via PTY spawn):
194+ * CI=undefined — drop the key so prompts render.
195+ * Fixture default is CI=1; Ink's `is-in-ci`
196+ * treats `'CI' in env` as CI even when ''.
197+ * In CI mode Ink suppresses prompt frames
198+ * (only emitted on unmount), so waitForOutput
199+ * hangs until the process is killed.
200+ * SHOPIFY_CLI_NEVER_USE_PARTNERS_API=1 — skip Partners client in fetchOrganizations.
201+ * Without this, fetchOrganizations iterates
202+ * AppManagement AND Partners sequentially.
203+ * Partners requires SHOPIFY_CLI_PARTNERS_TOKEN
204+ * (not set in OAuth-auth'd tests) and hangs
205+ * for minutes trying to authenticate. The e2e
206+ * test org (161686155) lives in AppManagement.
207+ */
182208export async function configLink (
183209 ctx : CLIContext & {
184- clientId : string
210+ appName : string
211+ orgId : string
212+ configName ?: string
185213 } ,
186214) : Promise < ExecResult > {
187- return ctx . cli . exec ( [ 'app' , 'config' , 'link' , '--path' , ctx . appDir , '--client-id' , ctx . clientId ] , {
188- timeout : CLI_TIMEOUT . medium ,
215+ const args = [ 'app' , 'config' , 'link' ]
216+ // Pass configName as --config flag. link.ts → loadConfigurationFileName skips
217+ // the "Configuration file name" prompt when options.configName is set, which
218+ // also side-steps a painful interactive quirk: that prompt uses
219+ // `initialAnswer = remoteApp.title`, so any text we write would be appended
220+ // to the app name rather than replacing it.
221+ if ( ctx . configName ) args . push ( '--config' , ctx . configName )
222+ args . push ( '--path' , ctx . appDir )
223+
224+ const proc = await ctx . cli . spawn ( args , {
225+ env : {
226+ CI : undefined ,
227+ SHOPIFY_CLI_NEVER_USE_PARTNERS_API : '1' ,
228+ } ,
189229 } )
230+
231+ // Short sleep so Ink's useInput hooks attach before we start writing.
232+ // Without this, an Enter press arrives mid-mount and a subsequent render can
233+ // flip the prompt state unexpectedly (e.g. turning a select into search mode).
234+ const settle = ( ms = 150 ) => new Promise < void > ( ( resolve ) => setTimeout ( resolve , ms ) )
235+
236+ try {
237+ // The first prompt is either the multi-org selector or — when the account
238+ // has only one org, or none of the orgs have existing apps — we jump
239+ // straight to `createAsNewAppPrompt`. Race both.
240+ const firstPrompt = await Promise . race ( [
241+ proc . waitForOutput ( 'Which organization' , CLI_TIMEOUT . medium ) . then ( ( ) => 'org' as const ) ,
242+ proc . waitForOutput ( 'Create this project as a new app' , CLI_TIMEOUT . medium ) . then ( ( ) => 'create' as const ) ,
243+ proc . waitForOutput ( 'App name' , CLI_TIMEOUT . medium ) . then ( ( ) => 'appName' as const ) ,
244+ ] )
245+
246+ if ( firstPrompt === 'org' ) {
247+ // Type the orgId to filter the autocomplete prompt to exactly one match.
248+ // selectOrganizationPrompt's label includes `(${org.id})` when duplicate
249+ // org names exist (which is true for the e2e test account), so substring
250+ // matching on the numeric ID is unique. Avoids relying on MRU ordering.
251+ await settle ( )
252+ proc . ptyProcess . write ( ctx . orgId )
253+ await settle ( )
254+ proc . sendKey ( '\r' )
255+ // After org selection the CLI fetches apps for the chosen org. If
256+ // the org has existing apps → "Create this project" prompt. If it has
257+ // zero apps → selectOrCreateApp skips straight to appNamePrompt.
258+ const next = await Promise . race ( [
259+ proc . waitForOutput ( 'Create this project as a new app' , CLI_TIMEOUT . medium ) . then ( ( ) => 'create' as const ) ,
260+ proc . waitForOutput ( 'App name' , CLI_TIMEOUT . medium ) . then ( ( ) => 'appName' as const ) ,
261+ ] )
262+ if ( next === 'create' ) {
263+ await settle ( )
264+ proc . sendKey ( '\r' )
265+ }
266+ } else if ( firstPrompt === 'create' ) {
267+ await settle ( )
268+ proc . sendKey ( '\r' )
269+ }
270+
271+ // Wait for "App name" text prompt and submit the desired name.
272+ // Important: Ink parses each PTY data event as ONE keypress. If we write
273+ // "name\r" in one call, parseKeypress sees the whole string and treats
274+ // it as text (not Enter), so the prompt never submits. We must write the
275+ // text, wait for it to be consumed, then write \r separately.
276+ await proc . waitForOutput ( 'App name' , CLI_TIMEOUT . medium )
277+ await settle ( )
278+ proc . ptyProcess . write ( ctx . appName )
279+ await settle ( )
280+ proc . sendKey ( '\r' )
281+
282+ const exitCode = await proc . waitForExit ( CLI_TIMEOUT . long )
283+ return { exitCode, stdout : proc . getOutput ( ) , stderr : '' }
284+ } finally {
285+ proc . kill ( )
286+ }
190287}
191288
192289// ---------------------------------------------------------------------------
0 commit comments