Skip to content

Commit bdb504d

Browse files
authored
Merge pull request #6475 from Shopify/10-03-capture_conf_update_errors
Capture Conf update errors
2 parents abed43c + 33b6549 commit bdb504d

File tree

4 files changed

+310
-7
lines changed

4 files changed

+310
-7
lines changed

packages/cli-kit/src/public/node/fs.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
copyFile,
44
mkdir,
55
fileHasExecutablePermissions,
6+
fileHasWritePermissions,
7+
unixFileIsOwnedByCurrentUser,
68
writeFile,
79
readFile,
810
inTemporaryDirectory,
@@ -388,3 +390,101 @@ describe('copyDirectoryContents', () => {
388390
})
389391
})
390392
})
393+
394+
describe('fileHasWritePermissions', () => {
395+
test('returns true when file has write permissions', async () => {
396+
await inTemporaryDirectory(async (tmpDir) => {
397+
// Given
398+
const filePath = joinPath(tmpDir, 'writable-file.txt')
399+
await writeFile(filePath, 'test content')
400+
401+
// When
402+
const result = await fileHasWritePermissions(filePath)
403+
404+
// Then
405+
expect(result).toBe(true)
406+
})
407+
})
408+
409+
test('returns false when file does not have write permissions', async () => {
410+
await inTemporaryDirectory(async (tmpDir) => {
411+
// Given
412+
const filePath = joinPath(tmpDir, 'readonly-file.txt')
413+
await writeFile(filePath, 'test content')
414+
// Read-only
415+
await chmod(filePath, 0o444)
416+
417+
// When
418+
const result = await fileHasWritePermissions(filePath)
419+
420+
// Then
421+
expect(result).toBe(false)
422+
})
423+
})
424+
425+
test('returns false when file does not exist', async () => {
426+
await inTemporaryDirectory(async (tmpDir) => {
427+
// Given
428+
const filePath = joinPath(tmpDir, 'nonexistent-file.txt')
429+
430+
// When
431+
const result = await fileHasWritePermissions(filePath)
432+
433+
// Then
434+
expect(result).toBe(false)
435+
})
436+
})
437+
438+
test('returns true when directory has write permissions', async () => {
439+
await inTemporaryDirectory(async (tmpDir) => {
440+
// When
441+
const result = await fileHasWritePermissions(tmpDir)
442+
443+
// Then
444+
expect(result).toBe(true)
445+
})
446+
})
447+
})
448+
449+
describe('unixFileIsOwnedByCurrentUser', () => {
450+
test.skipIf(process.platform === 'win32')('returns true when file is owned by current user', async () => {
451+
await inTemporaryDirectory(async (tmpDir) => {
452+
// Given
453+
const filePath = joinPath(tmpDir, 'my-file.txt')
454+
await writeFile(filePath, 'test content')
455+
456+
// When
457+
const result = await unixFileIsOwnedByCurrentUser(filePath)
458+
459+
// Then
460+
expect(result).toBe(true)
461+
})
462+
})
463+
464+
test.skipIf(process.platform === 'win32')('returns false when file does not exist', async () => {
465+
await inTemporaryDirectory(async (tmpDir) => {
466+
// Given
467+
const filePath = joinPath(tmpDir, 'nonexistent-file.txt')
468+
469+
// When
470+
const result = await unixFileIsOwnedByCurrentUser(filePath)
471+
472+
// Then
473+
expect(result).toBe(false)
474+
})
475+
})
476+
477+
test.runIf(process.platform === 'win32')('returns undefined on Windows', async () => {
478+
await inTemporaryDirectory(async (tmpDir) => {
479+
// Given
480+
const filePath = joinPath(tmpDir, 'my-file.txt')
481+
await writeFile(filePath, 'test content')
482+
483+
// When
484+
const result = await unixFileIsOwnedByCurrentUser(filePath)
485+
486+
// Then
487+
expect(result).toBe(undefined)
488+
})
489+
})
490+
})

packages/cli-kit/src/public/node/fs.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ import {
2828
constants as fsConstants,
2929
existsSync as fsFileExistsSync,
3030
unlinkSync as fsUnlinkSync,
31+
accessSync,
3132
ReadStream,
3233
WriteStream,
34+
statSync,
3335
} from 'fs'
3436
import {
3537
mkdir as fsMkdir,
@@ -440,6 +442,32 @@ export async function fileHasExecutablePermissions(path: string): Promise<boolea
440442
}
441443
}
442444

445+
export function fileHasWritePermissions(path: string): boolean {
446+
try {
447+
accessSync(path, fsConstants.W_OK)
448+
return true
449+
// eslint-disable-next-line no-catch-all/no-catch-all
450+
} catch {
451+
return false
452+
}
453+
}
454+
455+
export function unixFileIsOwnedByCurrentUser(path: string): boolean | undefined {
456+
// process.getuid() is only available on Unix-like systems
457+
if (process.platform === 'win32' || typeof process.getuid !== 'function') return undefined
458+
if (!fileExistsSync(path)) return false
459+
460+
try {
461+
const stats = statSync(path)
462+
const currentUid = process.getuid()
463+
464+
return stats.uid === currentUid
465+
// eslint-disable-next-line no-catch-all/no-catch-all
466+
} catch {
467+
return false
468+
}
469+
}
470+
443471
/**
444472
* Returns true if a file or directory exists.
445473
*

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

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
/* eslint-disable @typescript-eslint/dot-notation */
12
import {LocalStorage} from './local-storage.js'
23
import {inTemporaryDirectory} from './fs.js'
3-
import {describe, expect, test} from 'vitest'
4+
import {AbortError} from './error.js'
5+
import * as fs from './fs.js'
6+
import {describe, expect, test, vi} from 'vitest'
47

58
interface TestSchema {
69
testValue: string
@@ -46,12 +49,106 @@ describe('storage', () => {
4649
// When
4750
storage.set('testValue', 'test')
4851
const got = storage.get('testValue')
49-
storage.delete('testValue')
50-
const got2 = storage.clear()
52+
storage.clear()
53+
const got2 = storage.get('testValue')
5154

5255
// Then
5356
expect(got).toEqual('test')
5457
expect(got2).toEqual(undefined)
5558
})
5659
})
5760
})
61+
62+
describe('error handling', () => {
63+
test('throws AbortError when file lacks write permissions', async () => {
64+
await inTemporaryDirectory(async (cwd) => {
65+
// Given
66+
const storage = new LocalStorage<TestSchema>({cwd})
67+
storage.set('testValue', 'test')
68+
69+
// Mock to simulate no write permissions
70+
vi.spyOn(fs, 'fileHasWritePermissions').mockReturnValue(false)
71+
vi.spyOn(fs, 'unixFileIsOwnedByCurrentUser').mockReturnValue(true)
72+
73+
// Mock Config to throw
74+
storage['config'].set = vi.fn(() => {
75+
throw new Error('EACCES: permission denied')
76+
})
77+
storage['config'].get = vi.fn(() => {
78+
throw new Error('EACCES: permission denied')
79+
})
80+
storage['config'].delete = vi.fn(() => {
81+
throw new Error('EACCES: permission denied')
82+
})
83+
storage['config'].clear = vi.fn(() => {
84+
throw new Error('EACCES: permission denied')
85+
})
86+
87+
// When/Then
88+
expect(() => storage.set('testValue', 'updated')).toThrow(AbortError)
89+
expect(() => storage.set('testValue', 'updated')).toThrow(/Failed to access local storage/)
90+
91+
expect(() => storage.get('testValue')).toThrow(AbortError)
92+
expect(() => storage.get('testValue')).toThrow(/Failed to access local storage/)
93+
94+
expect(() => storage.delete('testValue')).toThrow(AbortError)
95+
expect(() => storage.delete('testValue')).toThrow(/Failed to access local storage/)
96+
97+
expect(() => storage.clear()).toThrow(AbortError)
98+
expect(() => storage.clear()).toThrow(/Failed to access local storage/)
99+
})
100+
})
101+
102+
test('throws AbortError when file is owned by different user', async () => {
103+
await inTemporaryDirectory(async (cwd) => {
104+
// Given
105+
const storage = new LocalStorage<TestSchema>({cwd})
106+
storage.set('testValue', 'test')
107+
108+
// Mock to simulate ownership issue
109+
vi.spyOn(fs, 'fileHasWritePermissions').mockReturnValue(true)
110+
vi.spyOn(fs, 'unixFileIsOwnedByCurrentUser').mockReturnValue(false)
111+
112+
// Mock Config to throw
113+
114+
storage['config'].get = vi.fn(() => {
115+
throw new Error('EACCES: permission denied')
116+
})
117+
118+
// When/Then
119+
try {
120+
storage.get('testValue')
121+
expect.fail('Should have thrown')
122+
} catch (error) {
123+
if (error instanceof AbortError) {
124+
const tryMessageStr = JSON.stringify(error.tryMessage)
125+
expect(tryMessageStr).toContain('owned by a different user')
126+
expect(tryMessageStr).toContain('elevated permissions')
127+
expect(tryMessageStr).toContain('remove the Shopify CLI preferences folder')
128+
} else {
129+
throw error
130+
}
131+
}
132+
})
133+
})
134+
135+
test('throws BugError when error is not permission-related', async () => {
136+
await inTemporaryDirectory(async (cwd) => {
137+
// Given
138+
const storage = new LocalStorage<TestSchema>({cwd})
139+
140+
// Mock to simulate good permissions
141+
vi.spyOn(fs, 'fileHasWritePermissions').mockReturnValue(true)
142+
vi.spyOn(fs, 'unixFileIsOwnedByCurrentUser').mockReturnValue(true)
143+
144+
// Mock Config to throw non-permission error
145+
146+
storage['config'].set = vi.fn(() => {
147+
throw new Error('Invalid JSON format')
148+
})
149+
150+
// When/Then
151+
expect(() => storage.set('testValue', 'test')).toThrow(/Unexpected error while accessing local storage/)
152+
})
153+
})
154+
})

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

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {AbortError, BugError} from './error.js'
2+
import {fileHasWritePermissions, unixFileIsOwnedByCurrentUser} from './fs.js'
3+
import {dirname} from './path.js'
4+
import {TokenItem} from './ui.js'
15
import Config from 'conf'
26

37
/**
@@ -17,34 +21,108 @@ export class LocalStorage<T extends {[key: string]: any}> {
1721
*
1822
* @param key - The key to get.
1923
* @returns The value.
24+
* @throws AbortError if a permission error occurs.
25+
* @throws BugError if an unexpected error occurs.
2026
*/
2127
get<TKey extends keyof T>(key: TKey): T[TKey] | undefined {
22-
return this.config.get(key)
28+
try {
29+
return this.config.get(key)
30+
// eslint-disable-next-line no-catch-all/no-catch-all
31+
} catch (error) {
32+
this.handleError(error, 'get')
33+
}
2334
}
2435

2536
/**
2637
* Set a value in the local storage.
2738
*
2839
* @param key - The key to set.
2940
* @param value - The value to set.
41+
* @throws AbortError if a permission error occurs.
42+
* @throws BugError if an unexpected error occurs.
3043
*/
3144
set<TKey extends keyof T>(key: TKey, value?: T[TKey]): void {
32-
this.config.set(key, value)
45+
try {
46+
this.config.set(key, value)
47+
// eslint-disable-next-line no-catch-all/no-catch-all
48+
} catch (error) {
49+
this.handleError(error, 'set')
50+
}
3351
}
3452

3553
/**
3654
* Delete a value from the local storage.
3755
*
3856
* @param key - The key to delete.
57+
* @throws AbortError if a permission error occurs.
58+
* @throws BugError if an unexpected error occurs.
3959
*/
4060
delete<TKey extends keyof T>(key: TKey): void {
41-
this.config.delete(key)
61+
try {
62+
this.config.delete(key)
63+
// eslint-disable-next-line no-catch-all/no-catch-all
64+
} catch (error) {
65+
this.handleError(error, 'delete')
66+
}
4267
}
4368

4469
/**
4570
* Clear the local storage (delete all values).
71+
*
72+
* @throws AbortError if a permission error occurs.
73+
* @throws BugError if an unexpected error occurs.
4674
*/
4775
clear(): void {
48-
this.config.clear()
76+
try {
77+
this.config.clear()
78+
// eslint-disable-next-line no-catch-all/no-catch-all
79+
} catch (error) {
80+
this.handleError(error, 'clear')
81+
}
82+
}
83+
84+
/**
85+
* Handle errors from config operations.
86+
* If the error is permission-related, throw an AbortError with helpful hints.
87+
* Otherwise, throw a BugError.
88+
*
89+
* @param error - The error that occurred.
90+
* @param operation - The operation that failed.
91+
* @throws AbortError if the error is permission-related.
92+
* @throws BugError if the error is not permission-related.
93+
*/
94+
private handleError(error: unknown, operation: string): never {
95+
if (this.isPermissionError()) {
96+
throw new AbortError(`Failed to access local storage (${operation}): ${error}`, this.tryMessage())
97+
} else {
98+
throw new BugError(
99+
`Unexpected error while accessing local storage at ${this.config.path} (${operation}): ${error}`,
100+
)
101+
}
102+
}
103+
104+
private isPermissionError(): boolean {
105+
const canAccessFile = fileHasWritePermissions(this.config.path)
106+
const canAccessFolder = fileHasWritePermissions(dirname(this.config.path))
107+
const ownsFile = unixFileIsOwnedByCurrentUser(this.config.path)
108+
109+
return !canAccessFile || !canAccessFolder || ownsFile === false
110+
}
111+
112+
private tryMessage() {
113+
const ownsFile = unixFileIsOwnedByCurrentUser(this.config.path)
114+
const ownsFolder = unixFileIsOwnedByCurrentUser(dirname(this.config.path))
115+
116+
const message: TokenItem = [`Check that you have write permissions for`, {filePath: this.config.path}]
117+
if (ownsFile === false || ownsFolder === false) {
118+
message.push(
119+
'- The file is owned by a different user. This typically happens when Shopify CLI was previously run with elevated permissions (e.g., sudo).',
120+
)
121+
}
122+
123+
message.push('\n\nTo resolve this, remove the Shopify CLI preferences folder:')
124+
message.push({command: `rm -rf ${dirname(this.config.path)}`})
125+
126+
return message
49127
}
50128
}

0 commit comments

Comments
 (0)