11import { AbortError , BugError } from './error.js'
2- import { fileHasWritePermissions , unixFileIsOwnedByCurrentUser } from './fs.js'
3- import { dirname } from './path.js'
2+ import { fileExistsSync , fileHasWritePermissions , findPathUpSync , unixFileIsOwnedByCurrentUser } from './fs.js'
3+ import { dirname , resolvePath } from './path.js'
44import { TokenItem } from './ui.js'
55import Config from 'conf'
6+ import envPaths from 'env-paths'
7+
8+ function isFileSystemPermissionError ( error : unknown ) : error is NodeJS . ErrnoException {
9+ if ( ! ( error instanceof Error ) ) return false
10+ const errorCode = ( error as NodeJS . ErrnoException ) . code
11+ return errorCode === 'EACCES' || errorCode === 'EPERM'
12+ }
13+
14+ function configPathFromInitializationError (
15+ options : { projectName ?: string ; cwd ?: string } ,
16+ error : NodeJS . ErrnoException ,
17+ ) : string | undefined {
18+ if ( typeof error . path === 'string' ) {
19+ return error . syscall === 'mkdir' ? resolvePath ( error . path , 'config.json' ) : resolvePath ( error . path )
20+ }
21+
22+ const configDirectory =
23+ options . cwd ?? ( options . projectName ? envPaths ( options . projectName , { suffix : 'nodejs' } ) . config : undefined )
24+ return configDirectory ? resolvePath ( configDirectory , 'config.json' ) : undefined
25+ }
626
727function deserializeJson < T > ( value : string ) : T {
828 // Some Windows editors encode UTF-8 files with a byte order mark, which JSON.parse does not accept.
@@ -19,11 +39,20 @@ export class LocalStorage<T extends Record<string, any>> {
1939 private readonly config : Config < T >
2040
2141 constructor ( options : { projectName ?: string ; cwd ?: string } ) {
22- this . config = new Config < T > ( {
23- ...options ,
24- clearInvalidConfig : true ,
25- deserialize : deserializeJson < T > ,
26- } )
42+ try {
43+ this . config = new Config < T > ( {
44+ ...options ,
45+ clearInvalidConfig : true ,
46+ deserialize : deserializeJson < T > ,
47+ } )
48+ } catch ( error ) {
49+ if ( ! isFileSystemPermissionError ( error ) ) throw error
50+
51+ const configPath = configPathFromInitializationError ( options , error )
52+ if ( configPath ) this . handleError ( error , 'initialize' , configPath )
53+
54+ throw new AbortError ( `Failed to access local storage (initialize): ${ error } ` )
55+ }
2756 }
2857
2958 /**
@@ -98,40 +127,47 @@ export class LocalStorage<T extends Record<string, any>> {
98127 *
99128 * @param error - The error that occurred.
100129 * @param operation - The operation that failed.
130+ * @param configPath - The local storage configuration file path.
101131 * @throws AbortError if the error is permission-related.
102132 * @throws BugError if the error is not permission-related.
103133 */
104- private handleError ( error : unknown , operation : string ) : never {
105- if ( this . isPermissionError ( ) ) {
106- throw new AbortError ( `Failed to access local storage (${ operation } ): ${ error } ` , this . tryMessage ( ) )
134+ private handleError ( error : unknown , operation : string , configPath = this . config . path ) : never {
135+ if ( isFileSystemPermissionError ( error ) || this . isPermissionError ( configPath ) ) {
136+ throw new AbortError ( `Failed to access local storage (${ operation } ): ${ error } ` , this . tryMessage ( configPath ) )
107137 } else {
108- throw new BugError (
109- `Unexpected error while accessing local storage at ${ this . config . path } (${ operation } ): ${ error } ` ,
110- )
138+ throw new BugError ( `Unexpected error while accessing local storage at ${ configPath } (${ operation } ): ${ error } ` )
111139 }
112140 }
113141
114- private isPermissionError ( ) : boolean {
115- const canAccessFile = fileHasWritePermissions ( this . config . path )
116- const canAccessFolder = fileHasWritePermissions ( dirname ( this . config . path ) )
117- const ownsFile = unixFileIsOwnedByCurrentUser ( this . config . path )
142+ private isPermissionError ( configPath : string ) : boolean {
143+ const canAccessFile = fileHasWritePermissions ( configPath )
144+ const canAccessFolder = fileHasWritePermissions ( dirname ( configPath ) )
145+ const ownsFile = unixFileIsOwnedByCurrentUser ( configPath )
118146
119147 return ! canAccessFile || ! canAccessFolder || ownsFile === false
120148 }
121149
122- private tryMessage ( ) {
123- const ownsFile = unixFileIsOwnedByCurrentUser ( this . config . path )
124- const ownsFolder = unixFileIsOwnedByCurrentUser ( dirname ( this . config . path ) )
150+ private tryMessage ( configPath : string ) {
151+ const configDirectory = dirname ( configPath )
152+ const configDirectoryExists = fileExistsSync ( configDirectory )
153+ const permissionsPath = configDirectoryExists
154+ ? configPath
155+ : ( findPathUpSync ( '.' , { cwd : configDirectory , type : 'directory' } ) ?? configDirectory )
156+ const ownsFile = fileExistsSync ( configPath ) ? unixFileIsOwnedByCurrentUser ( configPath ) : undefined
157+ const ownershipDirectory = configDirectoryExists ? configDirectory : permissionsPath
158+ const ownsFolder = unixFileIsOwnedByCurrentUser ( ownershipDirectory )
125159
126- const message : TokenItem = [ `Check that you have write permissions for` , { filePath : this . config . path } ]
160+ const message : TokenItem = [ `Check that you have write permissions for` , { filePath : permissionsPath } ]
127161 if ( ownsFile === false || ownsFolder === false ) {
128162 message . push (
129163 '- The file is owned by a different user. This typically happens when Shopify CLI was previously run with elevated permissions (e.g., sudo).' ,
130164 )
131165 }
132166
133- message . push ( '\n\nTo resolve this, remove the Shopify CLI preferences folder:' )
134- message . push ( { command : `rm -rf ${ dirname ( this . config . path ) } ` } )
167+ if ( configDirectoryExists ) {
168+ message . push ( '\n\nTo resolve this, remove the Shopify CLI preferences folder:' )
169+ message . push ( { filePath : configDirectory } )
170+ }
135171
136172 return message
137173 }
0 commit comments