diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index ea2707e7d94..f911b753420 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -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", @@ -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 '?: 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 '?: 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 '?: 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 '?: 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 '?: 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 '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "themelanguageserver": { diff --git a/packages/cli/README.md b/packages/cli/README.md index 30a236cb243..065e4528d8c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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] @@ -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` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index b7157988e2a..7d50330e941 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -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": { @@ -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.", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index 79f6fd3dfdc..daa1870c2f1 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -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. diff --git a/packages/theme/src/cli/commands/theme/init.test.ts b/packages/theme/src/cli/commands/theme/init.test.ts new file mode 100644 index 00000000000..2ea8db2cb56 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/init.test.ts @@ -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()), + renderTextPrompt: vi.fn(), + renderWarning: vi.fn(), +})) +vi.mock('@shopify/cli-kit/node/system', async (importOriginal) => ({ + ...(await importOriginal()), + terminalSupportsPrompting: vi.fn(), +})) +vi.mock('../../services/init.js', async (importOriginal) => ({ + ...(await importOriginal()), + 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() +}) diff --git a/packages/theme/src/cli/commands/theme/init.ts b/packages/theme/src/cli/commands/theme/init.ts index 2ecd11be91e..2a90a8d986f 100644 --- a/packages/theme/src/cli/commands/theme/init.ts +++ b/packages/theme/src/cli/commands/theme/init.ts @@ -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' @@ -20,6 +22,10 @@ type InitFlags = InferredFlags type InitArgs = InferredArgs 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. @@ -43,6 +49,7 @@ export default class Init extends ThemeCommand { static flags = { ...globalFlags, + ...jsonFlag, path: themeFlags.path, 'clone-url': Flags.string({ char: 'u', @@ -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) { diff --git a/packages/theme/src/cli/services/init.test.ts b/packages/theme/src/cli/services/init.test.ts index 1f8466ebc44..8308b7de300 100644 --- a/packages/theme/src/cli/services/init.test.ts +++ b/packages/theme/src/cli/services/init.test.ts @@ -1,256 +1,106 @@ -import {cloneRepoAndCheckoutLatestTag, cloneRepo, createAIInstructions, createAIInstructionFiles} from './init.js' -import {describe, expect, vi, test, beforeEach} from 'vitest' +import { + cloneRepoAndCheckoutLatestTag, + cloneRepo, + createAIInstructions, + createAIInstructionFiles, + SKELETON_THEME_URL, +} from './init.js' +import {describe, expect, vi, test} from 'vitest' import {downloadGitRepository, removeGitRemote} from '@shopify/cli-kit/node/git' -import {rmdir, fileExists, readFile, writeFile, symlink} from '@shopify/cli-kit/node/fs' +import {fileExists, readFile, writeFile, mkdir, inTemporaryDirectory} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' vi.mock('@shopify/cli-kit/node/git') -vi.mock('@shopify/cli-kit/node/fs', async () => { - const actual = await vi.importActual('@shopify/cli-kit/node/fs') - return { - ...actual, - fileExists: vi.fn(), - rmdir: vi.fn(), - readFile: vi.fn(), - writeFile: vi.fn(), - symlink: vi.fn(), - inTemporaryDirectory: vi.fn(async (callback) => { - // eslint-disable-next-line n/no-callback-literal - return callback('/tmp') - }), - } -}) -vi.mock('@shopify/cli-kit/node/http') -vi.mock('@shopify/cli-kit/node/path') -vi.mock('@shopify/cli-kit/node/ui', async () => { - const actual = await vi.importActual('@shopify/cli-kit/node/ui') - return { - ...actual, - renderSelectPrompt: vi.fn(), - renderTasks: vi.fn(async (tasks: any[]) => { - for (const task of tasks) { + +describe.each([cloneRepo, cloneRepoAndCheckoutLatestTag])('%s', (clone) => { + test.each([SKELETON_THEME_URL, 'https://github.com/Shopify/dawn.git'])('clones and cleans up %s', async (repoUrl) => { + await inTemporaryDirectory(async (destination) => { + await Promise.all(['.github', '.cursor', '.claude', '.git'].map((name) => mkdir(joinPath(destination, name)))) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + const result = await runWithCommandEventsForCommand(['--json'], () => clone(repoUrl, destination)) + expect(result).toEqual({ + path: destination, + repoUrl, + latest: clone === cloneRepoAndCheckoutLatestTag, + aiInstructions: null, + instructionFiles: [], + }) + expect(stdout()).toBe('') + expect(stderr()).toContain('progress') + }) + expect(downloadGitRepository).toHaveBeenCalledWith({ + repoUrl, + destination, + latestTag: clone === cloneRepoAndCheckoutLatestTag ? true : undefined, + shallow: clone !== cloneRepoAndCheckoutLatestTag, + }) + expect(removeGitRemote).toHaveBeenCalledWith(destination) + for (const name of ['.github', '.cursor', '.claude', '.git']) { // eslint-disable-next-line no-await-in-loop - await task.task({}, task) + await expect(fileExists(joinPath(destination, name))).resolves.toBe(repoUrl !== SKELETON_THEME_URL) } - return {} - }), - } -}) - -describe('cloneRepoAndCheckoutLatestTag()', async () => { - beforeEach(() => { - vi.mocked(fileExists).mockResolvedValue(true) - vi.mocked(joinPath).mockImplementation((...paths) => paths.join('/')) - }) - - test('calls downloadRepository function from git service to clone a repo with latest tag', async () => { - // Given - const repoUrl = 'https://github.com/Shopify/dawn.git' - const destination = 'destination' - const latestTag = true - const shallow = true - - // When - await cloneRepoAndCheckoutLatestTag(repoUrl, destination) - - // Then - expect(downloadGitRepository).toHaveBeenCalledWith({repoUrl, destination, latestTag, shallow}) - }) - - test('removes git remote after cloning', async () => { - // Given - const repoUrl = 'https://github.com/Shopify/dawn.git' - const destination = 'destination' - - // When - await cloneRepoAndCheckoutLatestTag(repoUrl, destination) - - // Then - expect(removeGitRemote).toHaveBeenCalledWith(destination) - }) - - test('removes .github directory from skeleton theme after cloning when it exists', async () => { - // Given - const repoUrl = 'https://github.com/Shopify/skeleton-theme.git' - const destination = 'destination' - vi.mocked(fileExists).mockResolvedValue(true) - - // When - await cloneRepoAndCheckoutLatestTag(repoUrl, destination) - - // Then - expect(fileExists).toHaveBeenCalledWith('destination/.github') - expect(rmdir).toHaveBeenCalledWith('destination/.github') - }) - - test('doesnt remove .github directory from non-skeleton theme after cloning when it exists', async () => { - // Given - const repoUrl = 'https://github.com/Shopify/dawn.git' - const destination = 'destination' - vi.mocked(fileExists).mockResolvedValue(true) - - // When - await cloneRepoAndCheckoutLatestTag(repoUrl, destination) - - // Then - expect(rmdir).not.toHaveBeenCalledWith('destination/.github') - }) -}) - -describe('cloneRepo()', async () => { - beforeEach(() => { - vi.mocked(fileExists).mockResolvedValue(true) - vi.mocked(joinPath).mockImplementation((...paths) => paths.join('/')) - }) - - test('calls downloadRepository function from git service to clone a repo without branch', async () => { - // Given - const repoUrl = 'https://github.com/Shopify/dawn.git' - const destination = 'destination' - const shallow = true - // When - await cloneRepo(repoUrl, destination) - - // Then - expect(downloadGitRepository).toHaveBeenCalledWith({repoUrl, destination, shallow}) - }) - - test('removes git remote after cloning', async () => { - // Given - const repoUrl = 'https://github.com/Shopify/dawn.git' - const destination = 'destination' - - // When - await cloneRepo(repoUrl, destination) - - // Then - expect(removeGitRemote).toHaveBeenCalledWith(destination) - }) - - test('removes .github directory from skeleton theme after cloning when it exists', async () => { - // Given - const repoUrl = 'https://github.com/Shopify/skeleton-theme.git' - const destination = 'destination' - vi.mocked(fileExists).mockResolvedValue(true) - - // When - await cloneRepo(repoUrl, destination) - - // Then - expect(fileExists).toHaveBeenCalledWith('destination/.github') - expect(rmdir).toHaveBeenCalledWith('destination/.github') - }) - - test('doesnt remove .github directory from non-skeleton theme after cloning when it exists', async () => { - // Given - const repoUrl = 'https://github.com/Shopify/dawn.git' - const destination = 'destination' - vi.mocked(fileExists).mockResolvedValue(true) - - // When - await cloneRepo(repoUrl, destination) - - // Then - expect(rmdir).not.toHaveBeenCalledWith('destination/.github') + }) }) }) -describe('createAIInstructions()', () => { - const destination = '/path/to/theme' - - beforeEach(() => { - vi.mocked(joinPath).mockImplementation((...paths) => paths.join('/')) - vi.mocked(readFile).mockResolvedValue('Sample AI instructions content' as any) - vi.mocked(writeFile).mockResolvedValue() - vi.mocked(symlink).mockResolvedValue() - }) - - test('creates AI instructions for a single instruction type', async () => { - // Given - vi.mocked(downloadGitRepository).mockResolvedValue() - - // When - await createAIInstructions(destination, 'cursor') - - // Then - expect(downloadGitRepository).toHaveBeenCalled() - expect(readFile).toHaveBeenCalledWith('/tmp/ai/github/copilot-instructions.md') - expect(writeFile).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', expect.stringContaining('# AGENTS.md')) - expect(symlink).not.toHaveBeenCalled() - }) - - test('creates AI instructions for all instruction types when "all" is selected', async () => { - // Given - vi.mocked(downloadGitRepository).mockResolvedValue() - - // When - await createAIInstructions(destination, 'all') - - // Then - expect(downloadGitRepository).toHaveBeenCalled() - expect(readFile).toHaveBeenCalledTimes(1) - expect(writeFile).toHaveBeenCalledTimes(1) - expect(symlink).toHaveBeenCalledTimes(2) - expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/copilot-instructions.md') - expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/CLAUDE.md') - }) - - test('throws an error when file operations fail', async () => { - // Given - vi.mocked(downloadGitRepository).mockResolvedValue() - vi.mocked(readFile).mockRejectedValue(new Error('File not found')) - - await expect(createAIInstructions(destination, 'cursor')).rejects.toThrow('Failed to create AI instructions') +test.each(['cursor', 'github', 'claude', 'all'] as const)( + 'creates the requested %s instruction files', + async (choice) => { + vi.mocked(downloadGitRepository).mockImplementation(async ({destination}) => { + await mkdir(joinPath(destination, 'ai/github')) + await writeFile(joinPath(destination, 'ai/github/copilot-instructions.md'), 'AI instructions') + }) + await inTemporaryDirectory(async (destination) => { + await withCapturedStandardStreams(async () => { + const result = await runWithCommandEventsForCommand(['--json'], () => createAIInstructions(destination, choice)) + const names = ['AGENTS.md'] + if (choice === 'github' || choice === 'all') names.push('copilot-instructions.md') + if (choice === 'claude' || choice === 'all') names.push('CLAUDE.md') + expect(result.files).toEqual(names.map((name) => joinPath(destination, name))) + for (const path of result.files) { + // eslint-disable-next-line no-await-in-loop + await expect(readFile(path)).resolves.toBe('# AGENTS.md\n\nAI instructions') + } + }) + }) + }, +) + +test.each(['github', 'claude'] as const)( + 'falls back to copying %s when a symlink cannot be created', + async (choice) => { + await inTemporaryDirectory(async (directory) => { + const agentsPath = joinPath(directory, 'AGENTS.md') + const filename = choice === 'github' ? 'copilot-instructions.md' : 'CLAUDE.md' + await writeFile(agentsPath, 'Instructions') + // An existing file prevents symlink creation on every platform. + await writeFile(joinPath(directory, filename), 'Old instructions') + await expect(createAIInstructionFiles(directory, agentsPath, choice)).resolves.toEqual({copiedFile: filename}) + await expect(readFile(joinPath(directory, filename))).resolves.toBe('Instructions') + }) + }, +) + +test('preserves the error when instruction source files are missing', async () => { + await inTemporaryDirectory(async (directory) => { + await withCapturedStandardStreams(async () => { + await expect( + runWithCommandEventsForCommand(['--json'], () => createAIInstructions(directory, 'cursor')), + ).rejects.toThrow('Failed to create AI instructions') + }) }) }) -describe('createAIInstructionFiles()', () => { - const themeRoot = '/path/to/theme' - const agentsPath = '/path/to/theme/AGENTS.md' - - beforeEach(() => { - vi.mocked(joinPath).mockImplementation((...paths) => paths.join('/')) - vi.mocked(readFile).mockResolvedValue('AI instruction content' as any) - vi.mocked(writeFile).mockResolvedValue() - vi.mocked(symlink).mockResolvedValue() - }) - - test('creates symlink for github instruction', async () => { - // Givin/When - await createAIInstructionFiles(themeRoot, agentsPath, 'github') - - // Then - expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/copilot-instructions.md') - }) - - test('does not create symlink for cursor instruction (uses AGENTS.md natively)', async () => { - // When - await createAIInstructionFiles(themeRoot, agentsPath, 'cursor') - - // Then - expect(symlink).not.toHaveBeenCalled() - }) - - test('creates symlink for claude instruction', async () => { - // When - await createAIInstructionFiles(themeRoot, agentsPath, 'claude') - - // Then - expect(symlink).toHaveBeenCalledWith('/path/to/theme/AGENTS.md', '/path/to/theme/CLAUDE.md') - }) - - test('falls back to copying file when symlink fails with EPERM', async () => { - // Given - vi.mocked(symlink).mockRejectedValue(new Error('EPERM: operation not permitted')) - vi.mocked(readFile).mockResolvedValue('AGENTS.md content' as any) - - // When - const result = await createAIInstructionFiles(themeRoot, agentsPath, 'github') - - // Then - expect(symlink).toHaveBeenCalled() - expect(readFile).toHaveBeenCalledWith(agentsPath) - expect(writeFile).toHaveBeenCalledWith('/path/to/theme/copilot-instructions.md', 'AGENTS.md content') - expect(result.copiedFile).toBe('copilot-instructions.md') +test('propagates clone failures', async () => { + vi.mocked(downloadGitRepository).mockRejectedValue(new Error('Clone failed')) + await inTemporaryDirectory(async (directory) => { + await withCapturedStandardStreams(async ({stdout}) => { + await expect(runWithCommandEventsForCommand(['--json'], () => cloneRepo('repo', directory))).rejects.toThrow( + 'Clone failed', + ) + expect(stdout()).toBe('') + }) }) }) diff --git a/packages/theme/src/cli/services/init.ts b/packages/theme/src/cli/services/init.ts index 42e554827e2..1938764684e 100644 --- a/packages/theme/src/cli/services/init.ts +++ b/packages/theme/src/cli/services/init.ts @@ -1,4 +1,5 @@ -import {renderSelectPrompt, renderWarning, renderTasks} from '@shopify/cli-kit/node/ui' +import {type ThemeInitResult} from './init/types.js' +import {renderSelectPrompt, renderTasks} from '@shopify/cli-kit/node/ui' import {downloadGitRepository, removeGitRemote} from '@shopify/cli-kit/node/git' import {joinPath} from '@shopify/cli-kit/node/path' import {rmdir, fileExists, inTemporaryDirectory, readFile, writeFile, symlink} from '@shopify/cli-kit/node/fs' @@ -17,14 +18,14 @@ const SUPPORTED_AI_INSTRUCTIONS = { type AIInstruction = keyof typeof SUPPORTED_AI_INSTRUCTIONS export async function cloneRepo(repoUrl: string, destination: string) { - await downloadRepository(repoUrl, destination) + return downloadRepository(repoUrl, destination) } export async function cloneRepoAndCheckoutLatestTag(repoUrl: string, destination: string) { - await downloadRepository(repoUrl, destination, true) + return downloadRepository(repoUrl, destination, true) } -async function downloadRepository(repoUrl: string, destination: string, latestTag?: boolean) { +async function downloadRepository(repoUrl: string, destination: string, latestTag?: boolean): Promise { await renderTasks([ { title: `Cloning ${repoUrl} into ${destination}`, @@ -33,7 +34,8 @@ async function downloadRepository(repoUrl: string, destination: string, latestTa repoUrl, destination, latestTag, - shallow: true, + // Fetch tags and history when selecting the latest release. + shallow: !latestTag, }) await removeGitRemote(destination) @@ -48,6 +50,8 @@ async function downloadRepository(repoUrl: string, destination: string, latestTa }, }, ]) + + return {path: destination, repoUrl, latest: latestTag ?? false, aiInstructions: null, instructionFiles: []} } async function removeDirectory(path: string) { @@ -72,7 +76,8 @@ export async function promptAIInstruction() { } export async function createAIInstructions(themeRoot: string, aiInstruction: AIInstruction) { - const createdFiles: string[] = [] + const copiedFiles: string[] = [] + const files = [joinPath(themeRoot, 'AGENTS.md')] await renderTasks([ { @@ -103,10 +108,17 @@ export async function createAIInstructions(themeRoot: string, aiInstruction: AII instructions.map((instruction) => createAIInstructionFiles(themeRoot, agentsPath, instruction)), ) + files.push( + ...instructions.flatMap((instruction) => { + if (instruction === 'cursor') return [] + return [joinPath(themeRoot, instruction === 'github' ? 'copilot-instructions.md' : 'CLAUDE.md')] + }), + ) + // Collect files that were copied instead of symlinked results.forEach((result) => { if (result.copiedFile) { - createdFiles.push(result.copiedFile) + copiedFiles.push(result.copiedFile) } }) } catch (error) { @@ -117,14 +129,7 @@ export async function createAIInstructions(themeRoot: string, aiInstruction: AII }, ]) - if (createdFiles.length > 0) { - renderWarning({ - headline: 'Files created instead of symlinks.', - body: `Shopify CLI attempted to create symbolic links between AGENTS.md and ${createdFiles.join( - ', ', - )}, but your system doesn't have Developer Mode enabled or symlinks are disabled. Separate files were created instead.`, - }) - } + return {files, copiedFiles} } export async function createAIInstructionFiles( diff --git a/packages/theme/src/cli/services/init/result.ts b/packages/theme/src/cli/services/init/result.ts new file mode 100644 index 00000000000..453e32e02b0 --- /dev/null +++ b/packages/theme/src/cli/services/init/result.ts @@ -0,0 +1,24 @@ +import {themeInitJsonOutputSchema, type ThemeInitResult} from './types.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {emitCommandEvent} from '@shopify/cli-kit/node/command-events' +import {renderWarning} from '@shopify/cli-kit/node/ui' + +export function renderThemeInitResult(result: ThemeInitResult, format: 'text' | 'json'): void { + // Text mode already reports completion through the cloning and AI instruction tasks. + if (format === 'json') outputResult(themeInitJsonOutputSchema.encode(result)) +} + +export function renderAIInstructionsWarning(copiedFiles: string[], format: 'text' | 'json'): void { + if (copiedFiles.length === 0) return + + const headline = 'Files created instead of symlinks.' + const body = `Shopify CLI attempted to create symbolic links between AGENTS.md and ${copiedFiles.join( + ', ', + )}, but your system doesn't have Developer Mode enabled or symlinks are disabled. Separate files were created instead.` + + if (format === 'json') { + emitCommandEvent({type: 'diagnostic', level: 'warning', message: `${headline}\n${body}`}) + } else { + renderWarning({headline, body}) + } +} diff --git a/packages/theme/src/cli/services/init/types.ts b/packages/theme/src/cli/services/init/types.ts new file mode 100644 index 00000000000..389d365d77b --- /dev/null +++ b/packages/theme/src/cli/services/init/types.ts @@ -0,0 +1,15 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +export const themeInitJsonOutputSchema = defineJsonOutputSchema({ + name: 'ThemeInitResult', + schema: zod.object({ + path: zod.string(), + repoUrl: zod.string(), + latest: zod.boolean(), + aiInstructions: zod.enum(['all', 'github', 'cursor', 'claude']).nullable(), + instructionFiles: zod.array(zod.string()), + }), +}) + +export type ThemeInitResult = InferJsonOutputSchema