@@ -17,7 +17,7 @@ import {fetchOrCreateOrganizationApp} from './context.js'
1717import use from './app/config/use.js'
1818import { AbortError } from '@shopify/cli-kit/node/error'
1919import { fileRealPath , inTemporaryDirectory , mkdir , writeFile } from '@shopify/cli-kit/node/fs'
20- import { joinPath } from '@shopify/cli-kit/node/path'
20+ import { basename , joinPath } from '@shopify/cli-kit/node/path'
2121import { renderConfirmationPrompt } from '@shopify/cli-kit/node/ui'
2222import { afterEach , beforeEach , describe , expect , test , vi } from 'vitest'
2323import { symlink } from 'node:fs/promises'
@@ -77,6 +77,7 @@ describe('resolveAppSecuritySelection with an app configuration', () => {
7777 kind : 'config' ,
7878 appDirectory,
7979 appConfigFilePath : joinPath ( appDirectory , 'shopify.app.toml' ) ,
80+ appConfigDirectories : [ ] ,
8081 configClientId : 'default-client-id' ,
8182 clientIdOverride : undefined ,
8283 } )
@@ -326,6 +327,124 @@ describe('resolveAppSecuritySelection with several TOMLs and none selected', ()
326327 } )
327328} )
328329
330+ /** A TOML whose `extension_directories` and `web_directories` come first, since top-level keys must precede tables. */
331+ async function writeConfigurationWithDirectories (
332+ appDirectory : string ,
333+ directories : { extension_directories ?: string [ ] ; web_directories ?: string [ ] } ,
334+ name = 'shopify.app.toml' ,
335+ ) : Promise < void > {
336+ const keys = Object . entries ( directories ) . map ( ( [ key , entries ] ) => `${ key } = ${ JSON . stringify ( entries ) } \n` )
337+ await mkdir ( appDirectory )
338+ await writeFile ( joinPath ( appDirectory , name ) , `${ keys . join ( '' ) } ${ validAppConfiguration ( ) } ` )
339+ }
340+
341+ /** A theme extension named after its directory, since extension handles must be unique. */
342+ async function writeThemeExtension ( directory : string ) : Promise < void > {
343+ await mkdir ( directory )
344+ await writeFile ( joinPath ( directory , 'shopify.extension.toml' ) , `name = "${ basename ( directory ) } "\ntype = "theme"\n` )
345+ }
346+
347+ async function writeBackendWeb ( directory : string ) : Promise < void > {
348+ await mkdir ( directory )
349+ await writeFile (
350+ joinPath ( directory , 'shopify.web.toml' ) ,
351+ 'name = "backend"\nroles = ["backend"]\n\n[commands]\ndev = "dev"\n' ,
352+ )
353+ }
354+
355+ describe ( 'resolveAppSecuritySelection app configuration directories' , ( ) => {
356+ test ( 'are the directories of the extension and web TOMLs that relative entries match outside the app directory' , async ( ) => {
357+ await inTemporaryDirectory ( async ( directory ) => {
358+ const appDirectory = joinPath ( directory , 'app' )
359+ await writeConfigurationWithDirectories ( appDirectory , {
360+ extension_directories : [ 'extensions/*' , '../shared/*' , '../no-extensions/*' ] ,
361+ web_directories : [ '../backend' ] ,
362+ } )
363+ await writeThemeExtension ( joinPath ( appDirectory , 'extensions' , 'inside' ) )
364+ await writeThemeExtension ( joinPath ( directory , 'shared' , 'theme' ) )
365+ await writeFile ( joinPath ( directory , 'shared' , 'README.md' ) , 'Shared extensions\n' )
366+ await mkdir ( joinPath ( directory , 'no-extensions' , 'empty' ) )
367+ await writeBackendWeb ( joinPath ( directory , 'backend' ) )
368+
369+ const selection = await resolveAppSecuritySelection ( { path : appDirectory , allowPrompts : false } )
370+
371+ expect ( selection ) . toMatchObject ( {
372+ appConfigDirectories : [
373+ await fileRealPath ( joinPath ( directory , 'backend' ) ) ,
374+ await fileRealPath ( joinPath ( directory , 'shared' , 'theme' ) ) ,
375+ ] ,
376+ } )
377+ } )
378+ } )
379+
380+ test ( 'ignore absolute entries, the way the CLI does' , async ( ) => {
381+ await inTemporaryDirectory ( async ( directory ) => {
382+ const appDirectory = joinPath ( directory , 'app' )
383+ await writeConfigurationWithDirectories ( appDirectory , {
384+ extension_directories : [ joinPath ( directory , 'shared' , '*' ) ] ,
385+ web_directories : [ joinPath ( directory , 'backend' ) ] ,
386+ } )
387+ await writeThemeExtension ( joinPath ( directory , 'shared' , 'theme' ) )
388+ await writeBackendWeb ( joinPath ( directory , 'backend' ) )
389+
390+ const selection = await resolveAppSecuritySelection ( { path : appDirectory , allowPrompts : false } )
391+
392+ expect ( selection ) . toMatchObject ( { appConfigDirectories : [ ] } )
393+ } )
394+ } )
395+
396+ test ( 'leave out a directory reached through a symbolic link outside the app directory' , async ( ) => {
397+ await inTemporaryDirectory ( async ( directory ) => {
398+ const appDirectory = joinPath ( directory , 'app' )
399+ await writeConfigurationWithDirectories ( appDirectory , { extension_directories : [ '../linked-shared/*' ] } )
400+ await writeThemeExtension ( joinPath ( directory , 'shared' , 'theme' ) )
401+ await symlink ( joinPath ( directory , 'shared' ) , joinPath ( directory , 'linked-shared' ) , 'dir' )
402+
403+ const selection = await resolveAppSecuritySelection ( { path : appDirectory , allowPrompts : false } )
404+
405+ expect ( selection ) . toMatchObject ( { appConfigDirectories : [ ] } )
406+ } )
407+ } )
408+
409+ test ( 'leave out an extension directory inside the app directory that links outside it' , async ( ) => {
410+ await inTemporaryDirectory ( async ( directory ) => {
411+ const appDirectory = joinPath ( directory , 'app' )
412+ await writeConfigurationWithDirectories ( appDirectory , { extension_directories : [ 'extensions/*' ] } )
413+ await writeThemeExtension ( joinPath ( directory , 'shared' , 'theme' ) )
414+ await mkdir ( joinPath ( appDirectory , 'extensions' ) )
415+ await symlink ( joinPath ( directory , 'shared' , 'theme' ) , joinPath ( appDirectory , 'extensions' , 'theme' ) , 'dir' )
416+
417+ const selection = await resolveAppSecuritySelection ( { path : appDirectory , allowPrompts : false } )
418+
419+ expect ( selection ) . toMatchObject ( { appConfigDirectories : [ ] } )
420+ } )
421+ } )
422+
423+ test ( 'come from the selected TOML only' , async ( ) => {
424+ await inTemporaryDirectory ( async ( directory ) => {
425+ const appDirectory = joinPath ( directory , 'app' )
426+ await writeConfigurationWithDirectories ( appDirectory , { } )
427+ await writeFile (
428+ joinPath ( appDirectory , 'shopify.app.staging.toml' ) ,
429+ `extension_directories = ["../shared/*"]\n${ validAppConfiguration ( 'staging-client-id' ) } ` ,
430+ )
431+ await writeThemeExtension ( joinPath ( directory , 'shared' , 'theme' ) )
432+
433+ const defaultSelection = await resolveAppSecuritySelection ( { path : appDirectory , allowPrompts : false } )
434+ const stagingSelection = await resolveAppSecuritySelection ( {
435+ path : appDirectory ,
436+ config : 'staging' ,
437+ allowPrompts : false ,
438+ } )
439+
440+ expect ( defaultSelection ) . toMatchObject ( { appConfigDirectories : [ ] } )
441+ expect ( stagingSelection ) . toMatchObject ( {
442+ appConfigDirectories : [ await fileRealPath ( joinPath ( directory , 'shared' , 'theme' ) ) ] ,
443+ } )
444+ } )
445+ } )
446+ } )
447+
329448describe ( 'resolveAppSecuritySelection with --without-app-config' , ( ) => {
330449 test ( 'aborts without --client-id' , async ( ) => {
331450 await inTemporaryDirectory ( async ( directory ) => {
@@ -773,4 +892,35 @@ describe('mergeScanDirectories', () => {
773892 test ( 'accepts an include directory elsewhere on the same Windows drive' , ( ) => {
774893 expect ( mergeScanDirectories ( 'C:/work/app' , [ 'C:/backend' ] ) . scanDirectories ) . toHaveLength ( 2 )
775894 } )
895+
896+ test ( 'lists each app configuration directory after the include directories' , ( ) => {
897+ expect ( mergeScanDirectories ( '/work/app' , [ '/work/backend' ] , [ '/work/shared/theme' ] ) ) . toEqual ( {
898+ scanDirectories : [
899+ { directory : '/work/app' , origin : 'app_directory' } ,
900+ { directory : '/work/backend' , origin : 'include_dir' } ,
901+ { directory : '/work/shared/theme' , origin : 'app_config_directory' } ,
902+ ] ,
903+ requestedScanDirectories : [ '/work/app' , '/work/backend' , '/work/shared/theme' ] ,
904+ } )
905+ } )
906+
907+ test ( 'counts an app configuration directory that is also an include directory as the include directory' , ( ) => {
908+ expect ( mergeScanDirectories ( '/work/app' , [ '/work/shared/theme' ] , [ '/work/shared/theme' ] ) . scanDirectories ) . toEqual ( [
909+ { directory : '/work/app' , origin : 'app_directory' } ,
910+ { directory : '/work/shared/theme' , origin : 'include_dir' } ,
911+ ] )
912+ } )
913+
914+ test ( 'drops an app configuration directory inside an include directory' , ( ) => {
915+ expect ( mergeScanDirectories ( '/work/app' , [ '/work/shared' ] , [ '/work/shared/theme' ] ) . scanDirectories ) . toEqual ( [
916+ { directory : '/work/app' , origin : 'app_directory' } ,
917+ { directory : '/work/shared' , origin : 'include_dir' } ,
918+ ] )
919+ } )
920+
921+ test ( 'rejects an app configuration directory on another Windows drive' , ( ) => {
922+ expect ( ( ) => mergeScanDirectories ( 'C:/work/app' , [ ] , [ 'D:/shared/theme' ] ) ) . toThrowError (
923+ new AbortError ( 'Extension or web directory D:/shared/theme: must be on the same drive as the app directory.' ) ,
924+ )
925+ } )
776926} )
0 commit comments