Skip to content

Commit 98678ed

Browse files
authored
Merge pull request #6234 from Shopify/update-ai-instructions
Fetch and copy AI instructions from theme-liquid-docs
2 parents 4ac6e43 + 9b872dc commit 98678ed

File tree

10 files changed

+240
-121
lines changed

10 files changed

+240
-121
lines changed

.changeset/clever-kiwis-shout.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@shopify/cli': minor
3+
'@shopify/cli-kit': minor
4+
'@shopify/theme': minor
5+
---
6+
7+
Update fetched AI instructions

docs-shopify.dev/commands/theme-init.doc.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const data: ReferenceEntityTemplateSchema = {
55
name: 'theme init',
66
description: `Clones a Git repository to your local machine to use as the starting point for building a theme.
77
8-
If no Git repository is specified, then this command creates a copy of Shopify's [Skeleton theme](https://github.com/Shopify/skeleton-theme), with the specified name in the current folder. If no name is provided, then you're prompted to enter one.
8+
If no Git repository is specified, then this command creates a copy of Shopify's [Skeleton theme](https://github.com/Shopify/skeleton-theme.git), with the specified name in the current folder. If no name is provided, then you're prompted to enter one.
99
1010
> Caution: If you're building a theme for the Shopify Theme Store, then you can use our example theme as a starting point. However, the theme that you submit needs to be [substantively different from existing themes](/docs/themes/store/requirements#uniqueness) so that it provides added value for users.
1111
`,

docs-shopify.dev/generated/generated_docs_data.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5490,7 +5490,7 @@
54905490
},
54915491
{
54925492
"name": "theme init",
5493-
"description": "Clones a Git repository to your local machine to use as the starting point for building a theme.\n\n If no Git repository is specified, then this command creates a copy of Shopify's [Skeleton theme](https://github.com/Shopify/skeleton-theme), with the specified name in the current folder. If no name is provided, then you're prompted to enter one.\n\n > Caution: If you're building a theme for the Shopify Theme Store, then you can use our example theme as a starting point. However, the theme that you submit needs to be [substantively different from existing themes](/docs/themes/store/requirements#uniqueness) so that it provides added value for users.\n ",
5493+
"description": "Clones a Git repository to your local machine to use as the starting point for building a theme.\n\n If no Git repository is specified, then this command creates a copy of Shopify's [Skeleton theme](https://github.com/Shopify/skeleton-theme.git), with the specified name in the current folder. If no name is provided, then you're prompted to enter one.\n\n > Caution: If you're building a theme for the Shopify Theme Store, then you can use our example theme as a starting point. However, the theme that you submit needs to be [substantively different from existing themes](/docs/themes/store/requirements#uniqueness) so that it provides added value for users.\n ",
54945494
"overviewPreviewDescription": "Clones a Git repository to use as a starting point for building a new theme.",
54955495
"type": "command",
54965496
"isVisualComponent": false,

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

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ import {
1717
readFileSync,
1818
glob,
1919
detectEOL,
20+
copyDirectoryContents,
2021
} from './fs.js'
2122
import {joinPath} from './path.js'
2223
import {takeRandomFromArray} from '../common/array.js'
23-
import {describe, expect, test, vi} from 'vitest'
24+
import {beforeEach, describe, expect, test, vi} from 'vitest'
2425
import FastGlob from 'fast-glob'
2526
import * as os from 'os'
2627

@@ -322,3 +323,68 @@ describe('detectEOL', () => {
322323
expect(eol).toEqual('\n')
323324
})
324325
})
326+
327+
describe('copyDirectoryContents', () => {
328+
beforeEach(() => {
329+
// restore fast-glob to its original implementation for the tests
330+
vi.doMock('fast-glob', async () => {
331+
return vi.importActual('fast-glob')
332+
})
333+
})
334+
335+
test('copies the contents of source directory to destination directory', async () => {
336+
// Given
337+
await inTemporaryDirectory(async (tmpDir) => {
338+
const srcDir = joinPath(tmpDir, 'src')
339+
const destDir = joinPath(tmpDir, 'dest')
340+
await mkdir(srcDir)
341+
await mkdir(destDir)
342+
await writeFile(joinPath(srcDir, 'file'), 'test')
343+
await copyDirectoryContents(srcDir, destDir)
344+
345+
// Then
346+
await expect(readFile(joinPath(destDir, 'file'))).resolves.toEqual('test')
347+
})
348+
})
349+
350+
test('copies the contents of source directory to another directory when destination directory does not exist', async () => {
351+
// Given
352+
await inTemporaryDirectory(async (tmpDir) => {
353+
const srcDir = joinPath(tmpDir, 'src')
354+
const destDir = joinPath(tmpDir, 'dest')
355+
await mkdir(srcDir)
356+
await writeFile(joinPath(srcDir, 'file'), 'test')
357+
await copyDirectoryContents(srcDir, destDir)
358+
359+
// Then
360+
await expect(readFile(joinPath(destDir, 'file'))).resolves.toEqual('test')
361+
})
362+
})
363+
364+
test('copies the nested contents of source directory to destination directory', async () => {
365+
// Given
366+
await inTemporaryDirectory(async (tmpDir) => {
367+
const srcDir = joinPath(tmpDir, 'src')
368+
const destDir = joinPath(tmpDir, 'dest')
369+
await mkdir(srcDir)
370+
await mkdir(destDir)
371+
await mkdir(joinPath(srcDir, 'nested'))
372+
await writeFile(joinPath(srcDir, 'nested', 'file'), 'test')
373+
await copyDirectoryContents(srcDir, destDir)
374+
375+
// Then
376+
await expect(readFile(joinPath(destDir, 'nested', 'file'))).resolves.toEqual('test')
377+
})
378+
})
379+
380+
test('throws an error when the source directory does not exist', async () => {
381+
// Given
382+
await inTemporaryDirectory(async (tmpDir) => {
383+
const srcDir = joinPath(tmpDir, 'src')
384+
const destDir = joinPath(tmpDir, 'dest')
385+
386+
// When
387+
await expect(copyDirectoryContents(srcDir, destDir)).rejects.toThrow(`Source directory ${srcDir} does not exist`)
388+
})
389+
})
390+
})

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,3 +593,33 @@ export function matchGlob(key: string, pattern: string, options?: MatchGlobOptio
593593
export function readdir(path: string): Promise<string[]> {
594594
return fsReaddir(path)
595595
}
596+
597+
/**
598+
* Copies the contents of a directory to another directory.
599+
*
600+
* @param srcDir - Source directory path.
601+
* @param destDir - Destination directory path.
602+
*/
603+
export async function copyDirectoryContents(srcDir: string, destDir: string): Promise<void> {
604+
if (!(await fileExists(srcDir))) {
605+
throw new Error(`Source directory ${srcDir} does not exist`)
606+
}
607+
608+
if (!(await fileExists(destDir))) {
609+
await mkdir(destDir)
610+
}
611+
612+
// Get all files and directories in the source directory
613+
const items = await glob(joinPath(srcDir, '**/*'))
614+
615+
const filesToCopy = []
616+
617+
for (const item of items) {
618+
const relativePath = item.replace(srcDir, '').replace(/^[/\\]/, '')
619+
const destPath = joinPath(destDir, relativePath)
620+
621+
filesToCopy.push(copyFile(item, destPath))
622+
}
623+
624+
await Promise.all(filesToCopy)
625+
}

packages/cli/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2027,8 +2027,8 @@ ARGUMENTS
20272027

20282028
FLAGS
20292029
-l, --latest Downloads the latest release of the `clone-url`
2030-
-u, --clone-url=<value> [default: https://github.com/Shopify/skeleton-theme] The Git URL to clone from. Defaults to
2031-
Shopify's Skeleton theme.
2030+
-u, --clone-url=<value> [default: https://github.com/Shopify/skeleton-theme.git] The Git URL to clone from. Defaults
2031+
to Shopify's Skeleton theme.
20322032
--no-color Disable color output.
20332033
--path=<value> The path where you want to run the command. Defaults to the current working directory.
20342034
--verbose Increase the verbosity of the output.
@@ -2039,8 +2039,8 @@ DESCRIPTION
20392039
Clones a Git repository to your local machine to use as the starting point for building a theme.
20402040

20412041
If no Git repository is specified, then this command creates a copy of Shopify's "Skeleton theme"
2042-
(https://github.com/Shopify/skeleton-theme), with the specified name in the current folder. If no name is provided,
2043-
then you're prompted to enter one.
2042+
(https://github.com/Shopify/skeleton-theme.git), with the specified name in the current folder. If no name is
2043+
provided, then you're prompted to enter one.
20442044

20452045
> Caution: If you're building a theme for the Shopify Theme Store, then you can use our example theme as a starting
20462046
point. However, the theme that you submit needs to be "substantively different from existing themes"

packages/cli/oclif.manifest.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5704,12 +5704,12 @@
57045704
}
57055705
},
57065706
"customPluginName": "@shopify/theme",
5707-
"description": "Clones a Git repository to your local machine to use as the starting point for building a theme.\n\n If no Git repository is specified, then this command creates a copy of Shopify's \"Skeleton theme\" (https://github.com/Shopify/skeleton-theme), with the specified name in the current folder. If no name is provided, then you're prompted to enter one.\n\n > Caution: If you're building a theme for the Shopify Theme Store, then you can use our example theme as a starting point. However, the theme that you submit needs to be \"substantively different from existing themes\" (https://shopify.dev/docs/themes/store/requirements#uniqueness) so that it provides added value for users.\n ",
5708-
"descriptionWithMarkdown": "Clones a Git repository to your local machine to use as the starting point for building a theme.\n\n If no Git repository is specified, then this command creates a copy of Shopify's [Skeleton theme](https://github.com/Shopify/skeleton-theme), with the specified name in the current folder. If no name is provided, then you're prompted to enter one.\n\n > Caution: If you're building a theme for the Shopify Theme Store, then you can use our example theme as a starting point. However, the theme that you submit needs to be [substantively different from existing themes](https://shopify.dev/docs/themes/store/requirements#uniqueness) so that it provides added value for users.\n ",
5707+
"description": "Clones a Git repository to your local machine to use as the starting point for building a theme.\n\n If no Git repository is specified, then this command creates a copy of Shopify's \"Skeleton theme\" (https://github.com/Shopify/skeleton-theme.git), with the specified name in the current folder. If no name is provided, then you're prompted to enter one.\n\n > Caution: If you're building a theme for the Shopify Theme Store, then you can use our example theme as a starting point. However, the theme that you submit needs to be \"substantively different from existing themes\" (https://shopify.dev/docs/themes/store/requirements#uniqueness) so that it provides added value for users.\n ",
5708+
"descriptionWithMarkdown": "Clones a Git repository to your local machine to use as the starting point for building a theme.\n\n If no Git repository is specified, then this command creates a copy of Shopify's [Skeleton theme](https://github.com/Shopify/skeleton-theme.git), with the specified name in the current folder. If no name is provided, then you're prompted to enter one.\n\n > Caution: If you're building a theme for the Shopify Theme Store, then you can use our example theme as a starting point. However, the theme that you submit needs to be [substantively different from existing themes](https://shopify.dev/docs/themes/store/requirements#uniqueness) so that it provides added value for users.\n ",
57095709
"flags": {
57105710
"clone-url": {
57115711
"char": "u",
5712-
"default": "https://github.com/Shopify/skeleton-theme",
5712+
"default": "https://github.com/Shopify/skeleton-theme.git",
57135713
"description": "The Git URL to clone from. Defaults to Shopify's Skeleton theme.",
57145714
"env": "SHOPIFY_FLAG_CLONE_URL",
57155715
"hasDynamicHelp": false,

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import {themeFlags} from '../../flags.js'
22
import ThemeCommand from '../../utilities/theme-command.js'
3-
import {cloneRepoAndCheckoutLatestTag, cloneRepo, promptAndCreateAIFile} from '../../services/init.js'
3+
import {
4+
cloneRepoAndCheckoutLatestTag,
5+
cloneRepo,
6+
createAIInstructions,
7+
SKELETON_THEME_URL,
8+
promptAIInstruction,
9+
} from '../../services/init.js'
410
import {Args, Flags} from '@oclif/core'
511
import {globalFlags} from '@shopify/cli-kit/node/cli'
612
import {generateRandomNameForSubdirectory} from '@shopify/cli-kit/node/fs'
713
import {renderTextPrompt} from '@shopify/cli-kit/node/ui'
814
import {joinPath} from '@shopify/cli-kit/node/path'
915
import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system'
1016

11-
const SKELETON_THEME_URL = 'https://github.com/Shopify/skeleton-theme'
12-
1317
export default class Init extends ThemeCommand {
1418
static summary = 'Clones a Git repository to use as a starting point for building a new theme.'
1519

@@ -62,7 +66,13 @@ export default class Init extends ThemeCommand {
6266

6367
if (!terminalSupportsPrompting()) return
6468

65-
await promptAndCreateAIFile(destination)
69+
const aiInstruction = await promptAIInstruction()
70+
71+
if (!aiInstruction) {
72+
return
73+
}
74+
75+
await createAIInstructions(destination, aiInstruction)
6676
}
6777

6878
async promptName(directory: string) {

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

Lines changed: 53 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
1-
import {cloneRepoAndCheckoutLatestTag, cloneRepo, promptAndCreateAIFile} from './init.js'
1+
import {cloneRepoAndCheckoutLatestTag, cloneRepo, createAIInstructions} from './init.js'
22
import {describe, expect, vi, test, beforeEach} from 'vitest'
33
import {downloadGitRepository, removeGitRemote} from '@shopify/cli-kit/node/git'
4-
import {renderSelectPrompt} from '@shopify/cli-kit/node/ui'
5-
import {writeFile, rmdir, fileExists} from '@shopify/cli-kit/node/fs'
6-
import {fetch} from '@shopify/cli-kit/node/http'
4+
import {rmdir, fileExists, copyDirectoryContents} from '@shopify/cli-kit/node/fs'
75
import {joinPath} from '@shopify/cli-kit/node/path'
86

97
vi.mock('@shopify/cli-kit/node/git')
10-
vi.mock('@shopify/cli-kit/node/fs')
8+
vi.mock('@shopify/cli-kit/node/fs', async () => {
9+
const actual = await vi.importActual('@shopify/cli-kit/node/fs')
10+
return {
11+
...actual,
12+
fileExists: vi.fn(),
13+
rmdir: vi.fn(),
14+
copyDirectoryContents: vi.fn(),
15+
inTemporaryDirectory: vi.fn(async (callback) => {
16+
// eslint-disable-next-line node/no-callback-literal
17+
return callback('/tmp')
18+
}),
19+
}
20+
})
1121
vi.mock('@shopify/cli-kit/node/http')
1222
vi.mock('@shopify/cli-kit/node/path')
1323
vi.mock('@shopify/cli-kit/node/ui', async () => {
@@ -63,6 +73,19 @@ describe('cloneRepoAndCheckoutLatestTag()', async () => {
6373
expect(fileExists).toHaveBeenCalledWith('destination/.github')
6474
expect(rmdir).toHaveBeenCalledWith('destination/.github')
6575
})
76+
77+
test('doesnt remove .github directory from non-skeleton theme after cloning when it exists', async () => {
78+
// Given
79+
const repoUrl = 'https://github.com/Shopify/dawn.git'
80+
const destination = 'destination'
81+
vi.mocked(fileExists).mockResolvedValue(true)
82+
83+
// When
84+
await cloneRepoAndCheckoutLatestTag(repoUrl, destination)
85+
86+
// Then
87+
expect(rmdir).not.toHaveBeenCalledWith('destination/.github')
88+
})
6689
})
6790

6891
describe('cloneRepo()', async () => {
@@ -95,7 +118,7 @@ describe('cloneRepo()', async () => {
95118
expect(removeGitRemote).toHaveBeenCalledWith(destination)
96119
})
97120

98-
test('removes .github & .git directories from skeleton theme after cloning when it exists', async () => {
121+
test('removes .github directory from skeleton theme after cloning when it exists', async () => {
99122
// Given
100123
const repoUrl = 'https://github.com/Shopify/skeleton-theme.git'
101124
const destination = 'destination'
@@ -107,88 +130,47 @@ describe('cloneRepo()', async () => {
107130
// Then
108131
expect(fileExists).toHaveBeenCalledWith('destination/.github')
109132
expect(rmdir).toHaveBeenCalledWith('destination/.github')
110-
expect(fileExists).toHaveBeenCalledWith('destination/.git')
111-
expect(rmdir).toHaveBeenCalledWith('destination/.git')
112-
})
113-
})
114-
115-
describe('promptAndCreateAIFile()', () => {
116-
const destination = '/path/to/theme'
117-
const aiFileUrl = 'https://raw.githubusercontent.com/Shopify/theme-liquid-docs/main/ai/liquid.mdc'
118-
const mockFileContent = 'AI file content 🤖✨'
119-
120-
beforeEach(() => {
121-
vi.mocked(fetch).mockResolvedValue({
122-
text: vi.fn().mockResolvedValue(mockFileContent),
123-
} as any)
124133
})
125134

126-
test('creates VSCode AI file when vscode option is selected', async () => {
135+
test('doesnt remove .github directory from non-skeleton theme after cloning when it exists', async () => {
127136
// Given
128-
vi.mocked(renderSelectPrompt).mockResolvedValue('vscode')
129-
vi.mocked(joinPath)
130-
.mockReturnValueOnce('/path/to/theme/.github')
131-
.mockReturnValueOnce('/path/to/theme/.github/copilot-instructions.md')
137+
const repoUrl = 'https://github.com/Shopify/dawn.git'
138+
const destination = 'destination'
139+
vi.mocked(fileExists).mockResolvedValue(true)
132140

133141
// When
134-
await promptAndCreateAIFile(destination)
142+
await cloneRepo(repoUrl, destination)
135143

136144
// Then
137-
expect(renderSelectPrompt).toHaveBeenCalledWith({
138-
message: 'Set up AI dev support?',
139-
choices: [
140-
{label: 'VSCode (GitHub Copilot)', value: 'vscode'},
141-
{label: 'Cursor', value: 'cursor'},
142-
{label: 'Skip', value: 'none'},
143-
],
144-
})
145-
146-
expect(fetch).toHaveBeenCalledWith(aiFileUrl)
147-
expect(writeFile).toHaveBeenCalledWith('/path/to/theme/.github/copilot-instructions.md', mockFileContent)
145+
expect(rmdir).not.toHaveBeenCalledWith('destination/.github')
146+
})
147+
})
148+
149+
describe('createAIInstructions()', () => {
150+
const destination = '/path/to/theme'
151+
152+
beforeEach(() => {
153+
vi.mocked(joinPath).mockImplementation((...paths) => paths.join('/'))
148154
})
149155

150-
test('creates Cursor AI file when cursor option is selected', async () => {
156+
test('creates AI instructions if it exists', async () => {
151157
// Given
152-
vi.mocked(renderSelectPrompt).mockResolvedValue('cursor')
153-
vi.mocked(joinPath)
154-
.mockReturnValueOnce('/path/to/theme/.cursor/rules')
155-
.mockReturnValueOnce('/path/to/theme/.cursor/rules/liquid.mdc')
158+
vi.mocked(downloadGitRepository).mockResolvedValue()
159+
vi.mocked(copyDirectoryContents).mockResolvedValue()
156160

157161
// When
158-
await promptAndCreateAIFile(destination)
162+
await createAIInstructions(destination, 'cursor')
159163

160164
// Then
161-
expect(renderSelectPrompt).toHaveBeenCalledWith({
162-
message: 'Set up AI dev support?',
163-
choices: [
164-
{label: 'VSCode (GitHub Copilot)', value: 'vscode'},
165-
{label: 'Cursor', value: 'cursor'},
166-
{label: 'Skip', value: 'none'},
167-
],
168-
})
169-
170-
expect(fetch).toHaveBeenCalledWith(aiFileUrl)
171-
expect(writeFile).toHaveBeenCalledWith('/path/to/theme/.cursor/rules/liquid.mdc', mockFileContent)
165+
expect(downloadGitRepository).toHaveBeenCalled()
166+
expect(copyDirectoryContents).toHaveBeenCalledWith('/tmp/ai/cursor', '/path/to/theme/.cursor')
172167
})
173168

174-
test('does not create any AI file when none option is selected', async () => {
169+
test('throws an error when the AI instructions directory does not exist', async () => {
175170
// Given
176-
vi.mocked(renderSelectPrompt).mockResolvedValue('none')
177-
178-
// When
179-
await promptAndCreateAIFile(destination)
171+
vi.mocked(downloadGitRepository).mockResolvedValue()
172+
vi.mocked(copyDirectoryContents).mockRejectedValue(new Error('Directory does not exist'))
180173

181-
// Then
182-
expect(renderSelectPrompt).toHaveBeenCalledWith({
183-
message: 'Set up AI dev support?',
184-
choices: [
185-
{label: 'VSCode (GitHub Copilot)', value: 'vscode'},
186-
{label: 'Cursor', value: 'cursor'},
187-
{label: 'Skip', value: 'none'},
188-
],
189-
})
190-
191-
expect(fetch).not.toHaveBeenCalled()
192-
expect(writeFile).not.toHaveBeenCalled()
174+
await expect(createAIInstructions(destination, 'cursor')).rejects.toThrow('Failed to create AI instructions')
193175
})
194176
})

0 commit comments

Comments
 (0)