Skip to content

Commit 8af7d48

Browse files
committed
.
.
1 parent 0275d5c commit 8af7d48

File tree

3 files changed

+96
-34
lines changed

3 files changed

+96
-34
lines changed

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,24 @@ export async function renameFile(from: string, to: string): Promise<void> {
273273
*/
274274
export async function symlink(target: string, path: string): Promise<void> {
275275
outputDebug(outputContent`Creating symbolic link from ${outputToken.path(path)} to ${outputToken.path(target)}...`)
276-
await fsSymlink(target, path)
276+
277+
// On Windows, we need to specify the type of symlink (file or dir)
278+
// Check if target is a directory
279+
let type: 'file' | 'dir' | 'junction' = 'file'
280+
281+
try {
282+
const stats = await fsLstat(target)
283+
if (stats.isDirectory()) {
284+
// Use junction for directories on Windows (doesn't require admin privileges)
285+
type = 'junction'
286+
}
287+
// eslint-disable-next-line no-catch-all/no-catch-all
288+
} catch {
289+
// If we can't stat the target, assume it's a file
290+
// This handles cases where the target doesn't exist yet
291+
}
292+
293+
await fsSymlink(target, path, type)
277294
}
278295

279296
/**

packages/theme/src/cli/services/init.test.ts

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -181,9 +181,10 @@ describe('createAIInstructions()', () => {
181181

182182
// Then
183183
expect(downloadGitRepository).toHaveBeenCalled()
184-
// github, cursor, claude
185-
expect(readFile).toHaveBeenCalledTimes(3)
186-
expect(writeFile).toHaveBeenCalledTimes(3)
184+
// Read source file once, write AGENTS.md once
185+
expect(readFile).toHaveBeenCalledTimes(1)
186+
expect(writeFile).toHaveBeenCalledTimes(1)
187+
// Create symlinks for github, cursor, claude
187188
expect(symlink).toHaveBeenCalledTimes(3)
188189
expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/copilot-instructions.md')
189190
expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/.cursorrules')
@@ -200,8 +201,8 @@ describe('createAIInstructions()', () => {
200201
})
201202

202203
describe('createAIInstructionFiles()', () => {
203-
const tempDir = '/tmp'
204204
const themeRoot = '/path/to/theme'
205+
const agentsPath = '/path/to/theme/AGENTS.md'
205206

206207
beforeEach(() => {
207208
vi.mocked(joinPath).mockImplementation((...paths) => paths.join('/'))
@@ -210,45 +211,53 @@ describe('createAIInstructionFiles()', () => {
210211
vi.mocked(symlink).mockResolvedValue()
211212
})
212213

213-
test('creates AGENTS.md with prepended header for github instruction', async () => {
214+
test('creates symlink for github instruction', async () => {
214215
// When
215-
await createAIInstructionFiles(tempDir, themeRoot, 'github')
216+
const result = await createAIInstructionFiles(themeRoot, agentsPath, 'github')
216217

217218
// Then
218-
expect(readFile).toHaveBeenCalledWith('/tmp/ai/github/copilot-instructions.md')
219-
expect(writeFile).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '# AGENTS.md\n\nAI instruction content')
220219
expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/copilot-instructions.md')
220+
expect(result.copiedFile).toBeUndefined()
221221
})
222222

223-
test('creates AGENTS.md and .cursorrules symlink for cursor instruction', async () => {
223+
test('creates symlink for cursor instruction', async () => {
224224
// When
225-
await createAIInstructionFiles(tempDir, themeRoot, 'cursor')
225+
const result = await createAIInstructionFiles(themeRoot, agentsPath, 'cursor')
226226

227227
// Then
228-
expect(readFile).toHaveBeenCalledWith('/tmp/ai/github/copilot-instructions.md')
229-
expect(writeFile).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '# AGENTS.md\n\nAI instruction content')
230228
expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/.cursorrules')
229+
expect(result.copiedFile).toBeUndefined()
231230
})
232231

233-
test('creates AGENTS.md and CLAUDE.md symlink for claude instruction', async () => {
232+
test('creates symlink for claude instruction', async () => {
234233
// When
235-
await createAIInstructionFiles(tempDir, themeRoot, 'claude')
234+
const result = await createAIInstructionFiles(themeRoot, agentsPath, 'claude')
236235

237236
// Then
238-
expect(readFile).toHaveBeenCalledWith('/tmp/ai/github/copilot-instructions.md')
239-
expect(writeFile).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '# AGENTS.md\n\nAI instruction content')
240237
expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/CLAUDE.md')
238+
expect(result.copiedFile).toBeUndefined()
241239
})
242240

243-
test('prepends header to source content', async () => {
241+
test('falls back to copying file when symlink fails with EPERM', async () => {
244242
// Given
245-
const sourceContent = 'Original content from repo'
246-
vi.mocked(readFile).mockResolvedValue(sourceContent as any)
243+
vi.mocked(symlink).mockRejectedValue(new Error('EPERM: operation not permitted'))
244+
vi.mocked(readFile).mockResolvedValue('AGENTS.md content' as any)
247245

248246
// When
249-
await createAIInstructionFiles(tempDir, themeRoot, 'github')
247+
const result = await createAIInstructionFiles(themeRoot, agentsPath, 'github')
250248

251249
// Then
252-
expect(writeFile).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', `# AGENTS.md\n\n${sourceContent}`)
250+
expect(symlink).toHaveBeenCalled()
251+
expect(readFile).toHaveBeenCalledWith(agentsPath)
252+
expect(writeFile).toHaveBeenCalledWith('/path/to/theme/copilot-instructions.md', 'AGENTS.md content')
253+
expect(result.copiedFile).toBe('copilot-instructions.md')
254+
})
255+
256+
test('throws error when symlink fails with non-EPERM error', async () => {
257+
// Given
258+
vi.mocked(symlink).mockRejectedValue(new Error('Some other error'))
259+
260+
// When/Then
261+
await expect(createAIInstructionFiles(themeRoot, agentsPath, 'github')).rejects.toThrow('Some other error')
253262
})
254263
})

packages/theme/src/cli/services/init.ts

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {renderSelectPrompt, renderTasks} from '@shopify/cli-kit/node/ui'
1+
import {renderSelectPrompt, renderWarning, renderTasks} from '@shopify/cli-kit/node/ui'
22
import {downloadGitRepository, removeGitRemote} from '@shopify/cli-kit/node/git'
33
import {joinPath} from '@shopify/cli-kit/node/path'
44
import {rmdir, fileExists, inTemporaryDirectory, readFile, writeFile, symlink} from '@shopify/cli-kit/node/fs'
@@ -72,6 +72,8 @@ export async function promptAIInstruction() {
7272
}
7373

7474
export async function createAIInstructions(themeRoot: string, aiInstruction: AIInstruction) {
75+
const createdFiles: string[] = []
76+
7577
await renderTasks([
7678
{
7779
title: `Adding AI instructions into ${themeRoot}`,
@@ -89,26 +91,47 @@ export async function createAIInstructions(themeRoot: string, aiInstruction: AII
8991
: [aiInstruction]
9092

9193
try {
92-
await Promise.all(
93-
instructions.map((instruction) => createAIInstructionFiles(tempDir, themeRoot, instruction)),
94+
const sourcePath = joinPath(tempDir, 'ai', 'github', 'copilot-instructions.md')
95+
const sourceContent = await readFile(sourcePath)
96+
97+
// Create AGENTS.md once
98+
const agentsPath = joinPath(themeRoot, 'AGENTS.md')
99+
const agentsContent = `# AGENTS.md\n\n${sourceContent}`
100+
await writeFile(agentsPath, agentsContent)
101+
102+
const results = await Promise.all(
103+
instructions.map((instruction) => createAIInstructionFiles(themeRoot, agentsPath, instruction)),
94104
)
105+
106+
// Collect files that were copied instead of symlinked
107+
results.forEach((result) => {
108+
if (result.copiedFile) {
109+
createdFiles.push(result.copiedFile)
110+
}
111+
})
95112
} catch (error) {
96113
throw new AbortError('Failed to create AI instructions')
97114
}
98115
})
99116
},
100117
},
101118
])
102-
}
103119

104-
export async function createAIInstructionFiles(tempDir: string, themeRoot: string, instruction: AIInstruction) {
105-
const sourcePath = joinPath(tempDir, 'ai', 'github', 'copilot-instructions.md')
106-
const sourceContent = await readFile(sourcePath)
107-
108-
const agentsPath = joinPath(themeRoot, 'AGENTS.md')
109-
const agentsContent = `# AGENTS.md\n\n${sourceContent}`
110-
await writeFile(agentsPath, agentsContent)
120+
if (createdFiles.length > 0) {
121+
renderWarning({
122+
headline: 'Files created instead of symlinks.',
123+
body: `Shopify CLI attempted to create symbolic links between AGENTS.md and ${createdFiles.join(
124+
', ',
125+
)}, but your system doesn't have Developer Mode enabled or symlinks are disabled. Separate files were created instead.`,
126+
})
127+
}
128+
}
111129

130+
export async function createAIInstructionFiles(
131+
themeRoot: string,
132+
agentsPath: string,
133+
instruction: AIInstruction,
134+
): Promise<{copiedFile?: string}> {
112135
const symlinkMap: {[key in Exclude<AIInstruction, 'all'>]: string} = {
113136
github: 'copilot-instructions.md',
114137
cursor: '.cursorrules',
@@ -118,5 +141,18 @@ export async function createAIInstructionFiles(tempDir: string, themeRoot: strin
118141
const symlinkName = symlinkMap[instruction as Exclude<AIInstruction, 'all'>]
119142
const symlinkPath = joinPath(themeRoot, symlinkName)
120143

121-
await symlink(agentsPath, symlinkPath)
144+
try {
145+
await symlink(agentsPath, symlinkPath)
146+
return {}
147+
} catch (error) {
148+
// On Windows, symlinks may require admin privileges or Developer Mode
149+
// Fall back to copying the file if symlink creation fails
150+
if (error instanceof Error) {
151+
const agentsContent = await readFile(agentsPath)
152+
await writeFile(symlinkPath, agentsContent)
153+
return {copiedFile: symlinkName}
154+
} else {
155+
throw error
156+
}
157+
}
122158
}

0 commit comments

Comments
 (0)