Skip to content

Commit 065cd24

Browse files
Fix local storage initialization permission errors
1 parent 4fd2b24 commit 065cd24

3 files changed

Lines changed: 173 additions & 25 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/cli-kit': patch
3+
---
4+
5+
Show recovery guidance instead of crashing when Shopify CLI cannot create a local storage directory

‎packages/cli-kit/src/public/node/local-storage.test.ts‎

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import {LocalStorage} from './local-storage.js'
2-
import {inTemporaryDirectory, readFile, writeFile} from './fs.js'
3-
import {AbortError} from './error.js'
2+
import {chmod, inTemporaryDirectory, mkdir, readFile, writeFile} from './fs.js'
3+
import {AbortError, BugError} from './error.js'
44
import {joinPath} from './path.js'
55
import * as fs from './fs.js'
6+
import Config from 'conf'
67
import {describe, expect, test, vi} from 'vitest'
78

89
interface TestSchema {
@@ -96,6 +97,112 @@ describe('storage', () => {
9697
})
9798

9899
describe('error handling', () => {
100+
test('throws AbortError for a structured EPERM during initialization', async () => {
101+
await inTemporaryDirectory((cwd) => {
102+
// Given
103+
const permissionError = Object.assign(new Error('Permission denied'), {
104+
code: 'EPERM',
105+
syscall: 'mkdir',
106+
path: joinPath(cwd, 'shopify-cli-test-nodejs'),
107+
})
108+
const storeSpy = vi.spyOn(Config.prototype, 'store', 'get').mockImplementationOnce(() => {
109+
throw permissionError
110+
})
111+
112+
try {
113+
// When
114+
const storage = new LocalStorage<TestSchema>({cwd})
115+
expect(storage).toBeDefined()
116+
expect.fail('Should have thrown')
117+
} catch (error) {
118+
// Then
119+
if (!(error instanceof AbortError)) throw error
120+
expect(error.message).toContain('Failed to access local storage (initialize)')
121+
} finally {
122+
storeSpy.mockRestore()
123+
}
124+
})
125+
})
126+
127+
test.skipIf(process.platform === 'win32')(
128+
'throws AbortError when the config directory cannot be created',
129+
async () => {
130+
await inTemporaryDirectory(async (cwd) => {
131+
// Given
132+
const readOnlyDirectory = joinPath(cwd, 'read-only')
133+
const configDirectory = joinPath(readOnlyDirectory, 'shopify-cli-test-nodejs')
134+
const configPath = joinPath(configDirectory, 'config.json')
135+
await mkdir(readOnlyDirectory)
136+
await chmod(readOnlyDirectory, 0o555)
137+
138+
try {
139+
// When
140+
const storage = new LocalStorage<TestSchema>({cwd: configDirectory})
141+
expect(storage).toBeDefined()
142+
expect.fail('Should have thrown')
143+
} catch (error) {
144+
// Then
145+
expect(error).toBeInstanceOf(AbortError)
146+
if (!(error instanceof AbortError)) throw error
147+
148+
expect(error.message).toContain('Failed to access local storage (initialize)')
149+
const tryMessage = JSON.stringify(error.tryMessage)
150+
expect(error.tryMessage).toContainEqual({filePath: readOnlyDirectory})
151+
expect(tryMessage).not.toContain(configPath)
152+
expect(error.tryMessage).not.toContainEqual({command: `rm -rf ${configDirectory}`})
153+
} finally {
154+
await chmod(readOnlyDirectory, 0o755)
155+
}
156+
})
157+
},
158+
)
159+
160+
test.skipIf(process.platform === 'win32')('throws AbortError when the config file cannot be read', async () => {
161+
await inTemporaryDirectory(async (cwd) => {
162+
// Given
163+
const configPath = joinPath(cwd, 'config.json')
164+
await writeFile(configPath, '{"testValue":"test"}')
165+
await chmod(configPath, 0o200)
166+
167+
try {
168+
// When
169+
const storage = new LocalStorage<TestSchema>({cwd})
170+
expect(storage).toBeDefined()
171+
expect.fail('Should have thrown')
172+
} catch (error) {
173+
// Then
174+
expect(error).toBeInstanceOf(AbortError)
175+
if (!(error instanceof AbortError)) throw error
176+
177+
expect(error.message).toContain('Failed to access local storage (initialize)')
178+
expect(error.tryMessage).toContainEqual({filePath: configPath})
179+
expect(error.tryMessage).toContainEqual({filePath: cwd})
180+
expect(JSON.stringify(error.tryMessage)).not.toContain('rm -rf')
181+
} finally {
182+
await chmod(configPath, 0o600)
183+
}
184+
})
185+
})
186+
187+
test('preserves initialization errors that are unrelated to permissions', async () => {
188+
await inTemporaryDirectory(async (cwd) => {
189+
// Given
190+
const filePath = joinPath(cwd, 'file')
191+
await writeFile(filePath, 'content')
192+
193+
try {
194+
// When
195+
const storage = new LocalStorage<TestSchema>({cwd: joinPath(filePath, 'config')})
196+
expect(storage).toBeDefined()
197+
expect.fail('Should have thrown')
198+
} catch (error) {
199+
// Then
200+
if ((error as NodeJS.ErrnoException).code !== 'ENOTDIR') throw error
201+
expect(error).not.toBeInstanceOf(BugError)
202+
}
203+
})
204+
})
205+
99206
test('throws AbortError when file lacks write permissions', async () => {
100207
await inTemporaryDirectory(async (cwd) => {
101208
// Given

‎packages/cli-kit/src/public/node/local-storage.ts‎

Lines changed: 59 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,28 @@
11
import {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'
44
import {TokenItem} from './ui.js'
55
import 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

727
function 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

Comments
 (0)