Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -7829,6 +7829,15 @@
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_VERBOSE"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/theme-init.interface.ts",
"syntaxKind": "PropertySignature",
"name": "-j, --json",
"value": "''",
"description": "Output the result as JSON. Automatically disables color output.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_JSON"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/theme-init.interface.ts",
"syntaxKind": "PropertySignature",
Expand All @@ -7848,7 +7857,7 @@
"environmentValue": "SHOPIFY_FLAG_CLONE_URL"
}
],
"value": "export interface themeinit {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The Git URL to clone from. Defaults to Shopify's Skeleton theme.\n * @environment SHOPIFY_FLAG_CLONE_URL\n */\n '-u, --clone-url <value>'?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Downloads the latest release of the `clone-url`\n * @environment SHOPIFY_FLAG_LATEST\n */\n '-l, --latest'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
"value": "export interface themeinit {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The Git URL to clone from. Defaults to Shopify's Skeleton theme.\n * @environment SHOPIFY_FLAG_CLONE_URL\n */\n '-u, --clone-url <value>'?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Downloads the latest release of the `clone-url`\n * @environment SHOPIFY_FLAG_LATEST\n */\n '-l, --latest'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
}
},
"themelanguageserver": {
Expand Down
58 changes: 58 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8482,6 +8482,10 @@ ARGUMENTS
[NAME] Name of the new theme

FLAGS
-j, --json
Output the result as JSON. Automatically disables color output.
[env: SHOPIFY_FLAG_JSON]

-l, --latest
Downloads the latest release of the `clone-url`
[env: SHOPIFY_FLAG_LATEST]
Expand Down Expand Up @@ -8523,6 +8527,60 @@ DESCRIPTION
> 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.


Use `--json-schema` to print the result, error, and event schemas.

Output from `--json` conforms to the `ThemeInitResult` schema.

```json
{
"type": "object",
"properties": {
"path": {
"type": "string"
},
"repoUrl": {
"type": "string"
},
"latest": {
"type": "boolean"
},
"aiInstructions": {
"anyOf": [
{
"type": "string",
"enum": [
"all",
"github",
"cursor",
"claude"
]
},
{
"type": "null"
}
]
},
"instructionFiles": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"path",
"repoUrl",
"latest",
"aiInstructions",
"instructionFiles"
],
"additionalProperties": false,
"title": "ThemeInitResult",
"$schema": "http://json-schema.org/draft-07/schema#"
}
```
```

## `shopify theme language-server`
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10269,7 +10269,7 @@
}
},
"customPluginName": "@shopify/theme",
"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 ",
"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 \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemeInitResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n },\n \"repoUrl\": {\n \"type\": \"string\"\n },\n \"latest\": {\n \"type\": \"boolean\"\n },\n \"aiInstructions\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"enum\": [\n \"all\",\n \"github\",\n \"cursor\",\n \"claude\"\n ]\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"instructionFiles\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"path\",\n \"repoUrl\",\n \"latest\",\n \"aiInstructions\",\n \"instructionFiles\"\n ],\n \"additionalProperties\": false,\n \"title\": \"ThemeInitResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```",
"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 ",
"enableJsonFlag": false,
"flags": {
Expand All @@ -10291,6 +10291,15 @@
"name": "clone-url",
"type": "option"
},
"json": {
"allowNo": false,
"char": "j",
"description": "Output the result as JSON. Automatically disables color output.",
"env": "SHOPIFY_FLAG_JSON",
"hidden": false,
"name": "json",
"type": "boolean"
},
"json-schema": {
"allowNo": false,
"description": "Print the command's JSON schemas.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ const commandExceptions = [
'packages/plugin-did-you-mean/src/commands/config/autocorrect/off.ts',
'packages/plugin-did-you-mean/src/commands/config/autocorrect/on.ts',
'packages/plugin-did-you-mean/src/commands/config/autocorrect/status.ts',
'packages/theme/src/cli/commands/theme/init.ts',
'packages/theme/src/cli/commands/theme/metafields/pull.ts',

// Streaming commands without a single finite result.
Expand Down
151 changes: 151 additions & 0 deletions packages/theme/src/cli/commands/theme/init.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import Init from './init.js'
import {createAIInstructions, promptAIInstruction, SKELETON_THEME_URL} from '../../services/init.js'
import {themeInitJsonOutputSchema} from '../../services/init/types.js'
import {renderAIInstructionsWarning} from '../../services/init/result.js'
import {Config} from '@oclif/core'
import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'
import {downloadGitRepository} from '@shopify/cli-kit/node/git'
import {inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'
import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system'
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
import {renderTextPrompt, renderWarning} from '@shopify/cli-kit/node/ui'
import {expect, test, vi} from 'vitest'

vi.mock('@shopify/cli-kit/node/git')
vi.mock('@shopify/cli-kit/node/ui', async (importOriginal) => ({
...(await importOriginal<typeof import('@shopify/cli-kit/node/ui')>()),
renderTextPrompt: vi.fn(),
renderWarning: vi.fn(),
}))
vi.mock('@shopify/cli-kit/node/system', async (importOriginal) => ({
...(await importOriginal<typeof import('@shopify/cli-kit/node/system')>()),
terminalSupportsPrompting: vi.fn(),
}))
vi.mock('../../services/init.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../services/init.js')>()),
promptAIInstruction: vi.fn(),
createAIInstructions: vi.fn(),
}))
vi.mock('@shopify/cli-kit/node/analytics', () => ({
recordEvent: vi.fn(),
compileData: vi.fn().mockReturnValue({timings: {}, errors: {}, retries: {}, events: {}}),
}))
vi.mock('@shopify/cli-kit/node/metadata')
vi.mock('@shopify/cli-kit/node/environments')

async function run(argv: string[]) {
const config = new Config({root: __dirname})
await config.load()
await new Init(argv, config).run()
}

test.each([true, false])('emits the cloned theme after skipped AI setup, interactive=%s', async (interactive) => {
vi.mocked(terminalSupportsPrompting).mockReturnValue(interactive)
vi.mocked(promptAIInstruction).mockResolvedValue(null)
await inTemporaryDirectory(async (directory) => {
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runWithCommandEventsForCommand(['--json'], () => run(['example', '--path', directory, '--json']))
expect(JSON.parse(stdout())).toEqual({
path: joinPath(directory, 'example'),
repoUrl: SKELETON_THEME_URL,
latest: false,
aiInstructions: null,
instructionFiles: [],
})
expect(
stderr()
.trim()
.split('\n')
.map((line) => JSON.parse(line)),
).toEqual(expect.arrayContaining([expect.objectContaining({type: 'progress'})]))
})
expect(createAIInstructions).not.toHaveBeenCalled()
})
})

test('preserves the name prompt in JSON mode and waits for AI instructions', async () => {
vi.mocked(terminalSupportsPrompting).mockReturnValue(true)
vi.mocked(renderTextPrompt).mockResolvedValue('chosen-name')
vi.mocked(promptAIInstruction).mockResolvedValue('claude')
await inTemporaryDirectory(async (directory) => {
const path = joinPath(directory, 'chosen-name')
const instructionFiles = [joinPath(path, 'AGENTS.md'), joinPath(path, 'CLAUDE.md')]
vi.mocked(createAIInstructions).mockImplementation(async () => {
await mkdir(path)
await writeFile(instructionFiles[0]!, 'Instructions')
return {files: instructionFiles, copiedFiles: ['CLAUDE.md']}
})
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runWithCommandEventsForCommand(['--json'], () =>
run(['--path', directory, '--latest', '--clone-url', 'https://example.com/theme.git', '--json']),
)
expect(JSON.parse(stdout())).toEqual({
path,
repoUrl: 'https://example.com/theme.git',
latest: true,
aiInstructions: 'claude',
instructionFiles,
})
expect(
stderr()
.trim()
.split('\n')
.map((line) => JSON.parse(line)),
).toContainEqual(
expect.objectContaining({type: 'diagnostic', level: 'warning', message: expect.stringContaining('CLAUDE.md')}),
)
})
expect(renderTextPrompt).toHaveBeenCalledOnce()
expect(createAIInstructions).toHaveBeenCalledWith(path, 'claude')
})
})

test('does not emit a success result when AI setup fails', async () => {
vi.mocked(terminalSupportsPrompting).mockReturnValue(true)
vi.mocked(promptAIInstruction).mockResolvedValue('cursor')
vi.mocked(createAIInstructions).mockRejectedValue(new Error('Failed to create AI instructions'))
await inTemporaryDirectory(async (directory) => {
await withCapturedStandardStreams(async ({stdout}) => {
await expect(
runWithCommandEventsForCommand(['--json'], () => run(['example', '--path', directory, '--json'])),
).rejects.toThrow('Failed to create AI instructions')
expect(stdout()).toBe('')
})
})
})

test('does not emit a result when cloning fails', async () => {
vi.mocked(downloadGitRepository).mockRejectedValue(new Error('Clone failed'))
await inTemporaryDirectory(async (directory) => {
await withCapturedStandardStreams(async ({stdout}) => {
await expect(
runWithCommandEventsForCommand(['--json'], () => run(['example', '--path', directory, '--json'])),
).rejects.toThrow('Clone failed')
expect(stdout()).toBe('')
})
})
})

test('keeps the copy warning in the text presenter', () => {
renderAIInstructionsWarning(['CLAUDE.md'], 'text')
expect(renderWarning).toHaveBeenCalledWith({
headline: 'Files created instead of symlinks.',
body: "Shopify CLI attempted to create symbolic links between AGENTS.md and CLAUDE.md, but your system doesn't have Developer Mode enabled or symlinks are disabled. Separate files were created instead.",
})
})

test('exposes the schema and rejects invalid instruction choices', () => {
expect(Init.jsonOutputSchema).toBe(themeInitJsonOutputSchema)
expect(Init.flags.json).toBeDefined()
expect(Init.description).toContain('--json-schema')
expect(() =>
themeInitJsonOutputSchema.validate({
path: '/theme',
repoUrl: SKELETON_THEME_URL,
latest: false,
aiInstructions: 'invalid',
instructionFiles: [],
}),
).toThrow()
})
36 changes: 22 additions & 14 deletions packages/theme/src/cli/commands/theme/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {
} from '../../services/init.js'
import ThemeCommand, {RequiredFlags} from '../../utilities/theme-command.js'
import {themeFlags} from '../../flags.js'
import {themeInitJsonOutputSchema} from '../../services/init/types.js'
import {renderThemeInitResult, renderAIInstructionsWarning} from '../../services/init/result.js'
import {Args, Flags} from '@oclif/core'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {generateRandomNameForSubdirectory} from '@shopify/cli-kit/node/fs'
import {renderTextPrompt} from '@shopify/cli-kit/node/ui'
import {joinPath} from '@shopify/cli-kit/node/path'
Expand All @@ -20,6 +22,10 @@ type InitFlags = InferredFlags<typeof Init.flags>
type InitArgs = InferredArgs<typeof Init.args>

export default class Init extends ThemeCommand {
static get jsonOutputSchema() {
return themeInitJsonOutputSchema
}

static summary = 'Clones a Git repository to use as a starting point for building a new theme.'

static descriptionWithMarkdown = `Clones a Git repository to your local machine to use as the starting point for building a theme.
Expand All @@ -43,6 +49,7 @@ export default class Init extends ThemeCommand {

static flags = {
...globalFlags,
...jsonFlag,
path: themeFlags.path,
'clone-url': Flags.string({
char: 'u',
Expand All @@ -64,21 +71,22 @@ export default class Init extends ThemeCommand {
const repoUrl = flags['clone-url']
const destination = joinPath(flags.path, name)

if (flags.latest) {
await cloneRepoAndCheckoutLatestTag(repoUrl, destination)
} else {
await cloneRepo(repoUrl, destination)
}

if (!terminalSupportsPrompting()) return

const aiInstruction = await promptAIInstruction()

if (!aiInstruction) {
return
const result = flags.latest
? await cloneRepoAndCheckoutLatestTag(repoUrl, destination)
: await cloneRepo(repoUrl, destination)
const format = flags.json ? 'json' : 'text'

if (terminalSupportsPrompting()) {
const aiInstruction = await promptAIInstruction()
if (aiInstruction) {
const instructions = await createAIInstructions(destination, aiInstruction)
result.aiInstructions = aiInstruction
result.instructionFiles = instructions.files
renderAIInstructionsWarning(instructions.copiedFiles, format)
}
}

await createAIInstructions(destination, aiInstruction)
renderThemeInitResult(result, format)
}

async promptName(directory: string) {
Expand Down
Loading
Loading