diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 36eaa443c98..99ed0e839a0 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -9294,6 +9294,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", @@ -9313,7 +9322,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 * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\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 * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\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 49035cf0ada..37e938ccf4c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14193,6 +14193,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] @@ -14238,6 +14242,97 @@ 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": { + "status": { + "type": "string", + "enum": [ + "success", + "partial" + ] + }, + "changed": { + "type": "boolean", + "const": true + }, + "directory": { + "$ref": "#/definitions/AbsolutePath" + }, + "repoUrl": { + "type": "string", + "minLength": 1, + "description": "The source Git remote (including HTTPS, SSH, or SCP-style Git URLs)." + }, + "latest": { + "type": "boolean" + }, + "aiInstructions": { + "anyOf": [ + { + "type": "string", + "enum": [ + "all", + "github", + "cursor", + "claude" + ] + }, + { + "type": "null" + } + ] + }, + "instructionFilePaths": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/definitions/AbsolutePath" + } + }, + { + "type": "null" + } + ], + "description": "Created instruction files, or null when setup failed before their completion could be established." + }, + "reason": { + "type": [ + "string", + "null" + ], + "description": "The reason for incomplete setup, or null when setup completed." + } + }, + "required": [ + "status", + "changed", + "directory", + "repoUrl", + "latest", + "aiInstructions", + "instructionFilePaths", + "reason" + ], + "additionalProperties": false, + "title": "ThemeInitResult", + "definitions": { + "AbsolutePath": { + "type": "string", + "description": "An absolute native filesystem path." + } + }, + "$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 a0885c6f9d5..91b132e2389 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11529,7 +11529,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 \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"success\",\n \"partial\"\n ]\n },\n \"changed\": {\n \"type\": \"boolean\",\n \"const\": true\n },\n \"directory\": {\n \"$ref\": \"#/definitions/AbsolutePath\"\n },\n \"repoUrl\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The source Git remote (including HTTPS, SSH, or SCP-style Git URLs).\"\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 \"instructionFilePaths\": {\n \"anyOf\": [\n {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/AbsolutePath\"\n }\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"Created instruction files, or null when setup failed before their completion could be established.\"\n },\n \"reason\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"The reason for incomplete setup, or null when setup completed.\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"directory\",\n \"repoUrl\",\n \"latest\",\n \"aiInstructions\",\n \"instructionFilePaths\",\n \"reason\"\n ],\n \"additionalProperties\": false,\n \"title\": \"ThemeInitResult\",\n \"definitions\": {\n \"AbsolutePath\": {\n \"type\": \"string\",\n \"description\": \"An absolute native filesystem path.\"\n }\n },\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": { @@ -11551,6 +11551,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 630871ee575..ef410dd9e41 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -5,7 +5,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', // App Security commands, launched before adopting typed JSON output. Remove entries as they adopt it. diff --git a/packages/theme/src/cli/commands/theme/init.test.ts b/packages/theme/src/cli/commands/theme/init.test.ts index 35547200a56..5ef44e887ce 100644 --- a/packages/theme/src/cli/commands/theme/init.test.ts +++ b/packages/theme/src/cli/commands/theme/init.test.ts @@ -1,113 +1,243 @@ import Init from './init.js' -import { - cloneLatestStableSkeletonTheme, - cloneRepo, - cloneRepoAndCheckoutLatestTag, - promptAIInstruction, -} from '../../services/init.js' -import {renderSelectPrompt, renderTextPrompt} from '@shopify/cli-kit/node/ui' -import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' -import {generateRandomNameForSubdirectory} from '@shopify/cli-kit/node/fs' +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 {beforeEach, describe, expect, test, vi} from 'vitest' - -vi.mock('../../services/init.js') -vi.mock('@shopify/cli-kit/node/system') -vi.mock('@shopify/cli-kit/node/fs', async () => ({ - ...(await vi.importActual('@shopify/cli-kit/node/fs')), - generateRandomNameForSubdirectory: vi.fn(), +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 {renderSelectPrompt, renderTextPrompt, renderWarning} from '@shopify/cli-kit/node/ui' +import {expect, test, vi} from 'vitest' +// Native JSON paths must preserve Windows separators instead of pathe normalization. +// eslint-disable-next-line no-restricted-imports +import {resolve as nativePath} from 'node:path' + +vi.mock('@shopify/cli-kit/node/git') +vi.mock('@shopify/cli-kit/node/github', async (importOriginal) => ({ + ...(await importOriginal()), + getLatestGitHubRelease: vi.fn(async () => ({tag_name: 'v1.0.0'})), })) -vi.mock('@shopify/cli-kit/node/ui', async () => ({ - ...(await vi.importActual('@shopify/cli-kit/node/ui')), - renderSelectPrompt: vi.fn(), +vi.mock('@shopify/cli-kit/node/ui', async (importOriginal) => ({ + ...(await importOriginal()), + renderSelectPrompt: vi.fn(async () => 'stable'), 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') -const commandConfig = new Config({root: __dirname}) - -async function run(argv: string[], name = 'my-theme') { - await commandConfig.load() - await new Init([...(name ? [name] : []), ...argv], commandConfig).run() +async function run(argv: string[]) { + const config = new Config({root: __dirname}) + await config.load() + await new Init(argv, config).run() } -describe('theme init', () => { - beforeEach(() => { - vi.mocked(terminalSupportsPrompting).mockReturnValue(true) - vi.mocked(promptAIInstruction).mockResolvedValue(null) +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({ + status: 'success', + changed: true, + directory: nativePath(directory, 'example'), + repoUrl: SKELETON_THEME_URL, + latest: true, + aiInstructions: null, + instructionFilePaths: [], + reason: null, + }) + expect( + stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)), + ).toEqual(expect.arrayContaining([expect.objectContaining({type: 'progress'})])) + }) + expect(createAIInstructions).not.toHaveBeenCalled() }) +}) - test('offers the stable release and upstream Skeleton theme in interactive mode', async () => { - vi.mocked(renderSelectPrompt).mockResolvedValue('stable') - - await run([]) - - expect(renderSelectPrompt).toHaveBeenCalledWith({ - message: 'Which version of Skeleton theme would you like to use?', - choices: [ - {label: 'Latest stable release', value: 'stable'}, - {label: 'Upstream (new features may not be available on your store)', value: 'upstream'}, - ], - defaultValue: 'stable', +test('preserves the name prompt in JSON mode and waits for AI instructions', async () => { + const {renderWarning: actualRenderWarning} = + await vi.importActual('@shopify/cli-kit/node/ui') + vi.mocked(renderWarning).mockImplementationOnce(actualRenderWarning) + 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']} }) - expect(cloneLatestStableSkeletonTheme).toHaveBeenCalledOnce() - expect(cloneRepo).not.toHaveBeenCalled() + 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({ + status: 'success', + changed: true, + directory: nativePath(path), + repoUrl: 'https://example.com/theme.git', + latest: true, + aiInstructions: 'claude', + instructionFilePaths: instructionFiles.map((path) => nativePath(path)), + reason: null, + }) + 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('clones the upstream Skeleton theme when selected', async () => { - vi.mocked(renderSelectPrompt).mockResolvedValue('upstream') - - await run([]) - - expect(cloneRepo).toHaveBeenCalledWith('https://github.com/Shopify/skeleton-theme.git', expect.any(String)) - expect(cloneLatestStableSkeletonTheme).not.toHaveBeenCalled() +test('preserves the cloned project 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, stderr}) => { + const previousExitCode = process.exitCode + try { + await runWithCommandEventsForCommand(['--json'], () => run(['example', '--path', directory, '--json'])) + expect(JSON.parse(stdout())).toEqual({ + status: 'partial', + changed: true, + directory: nativePath(directory, 'example'), + repoUrl: SKELETON_THEME_URL, + latest: true, + aiInstructions: 'cursor', + instructionFilePaths: null, + reason: 'Failed to create AI instructions', + }) + expect(stderr()).toContain('Failed to create AI instructions') + expect(process.exitCode).toBe(1) + } finally { + process.exitCode = previousExitCode + } + }) }) +}) - test.each([['--latest'], ['--no-input']])('uses the stable release with %s', async (flag) => { - await run([flag]) - - expect(renderSelectPrompt).not.toHaveBeenCalled() - expect(cloneLatestStableSkeletonTheme).toHaveBeenCalledOnce() +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('uses the stable release when the terminal cannot prompt', async () => { - vi.mocked(terminalSupportsPrompting).mockReturnValue(false) - - await run([]) - - expect(renderSelectPrompt).not.toHaveBeenCalled() - expect(cloneLatestStableSkeletonTheme).toHaveBeenCalledOnce() +test('keeps the copy warning in the text presenter', () => { + renderAIInstructionsWarning(['CLAUDE.md']) + 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('keeps the default branch for an interactive custom repository', async () => { - await run(['--clone-url=https://github.com/Shopify/dawn.git']) +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({ + status: 'success', + changed: true, + directory: nativePath('/theme'), + reason: null, + repoUrl: SKELETON_THEME_URL, + latest: false, + aiInstructions: 'invalid', + instructionFilePaths: [], + }), + ).toThrow() +}) +test('generates a name without prompting in non-interactive JSON mode', async () => { + vi.mocked(terminalSupportsPrompting).mockReturnValue(false) + await inTemporaryDirectory(async (directory) => { + await withCapturedStandardStreams(async ({stdout}) => { + await runWithCommandEventsForCommand(['--json'], () => run(['--path', directory, '--json'])) + const result = JSON.parse(stdout()) + expect(result).toMatchObject({status: 'success', changed: true, latest: true}) + expect(nativePath(result.directory, '..')).toBe(nativePath(directory)) + expect(result.directory).not.toBe(nativePath(directory)) + }) + expect(renderTextPrompt).not.toHaveBeenCalled() expect(renderSelectPrompt).not.toHaveBeenCalled() - expect(cloneRepo).toHaveBeenCalledWith('https://github.com/Shopify/dawn.git', expect.any(String)) - }) - - test('keeps the default branch for a custom repository with --no-input', async () => { - await run(['--clone-url=https://github.com/Shopify/dawn.git', '--no-input']) - - expect(cloneRepo).toHaveBeenCalledWith('https://github.com/Shopify/dawn.git', expect.any(String)) - expect(cloneRepoAndCheckoutLatestTag).not.toHaveBeenCalled() + expect(promptAIInstruction).not.toHaveBeenCalled() }) +}) - test('uses the latest tag for a custom repository with --latest', async () => { - await run(['--clone-url=https://github.com/Shopify/dawn.git', '--latest']) - - expect(cloneRepoAndCheckoutLatestTag).toHaveBeenCalledWith( - 'https://github.com/Shopify/dawn.git', - expect.any(String), +test.each([ + {argv: [], interactive: true, choice: 'stable', repoUrl: `${SKELETON_THEME_URL}#v1.0.0`, latest: true}, + {argv: [], interactive: true, choice: 'upstream', repoUrl: SKELETON_THEME_URL, latest: false}, + {argv: ['--latest'], interactive: true, repoUrl: `${SKELETON_THEME_URL}#v1.0.0`, latest: true}, + {argv: ['--no-input'], interactive: true, repoUrl: `${SKELETON_THEME_URL}#v1.0.0`, latest: true}, + {argv: [], interactive: false, repoUrl: `${SKELETON_THEME_URL}#v1.0.0`, latest: true}, + { + argv: ['--clone-url', 'https://example.com/theme.git'], + interactive: true, + repoUrl: 'https://example.com/theme.git', + latest: false, + }, + { + argv: ['--clone-url', 'https://example.com/theme.git', '--no-input'], + interactive: true, + repoUrl: 'https://example.com/theme.git', + latest: false, + }, + { + argv: ['--clone-url', 'https://example.com/theme.git', '--latest'], + interactive: true, + repoUrl: 'https://example.com/theme.git', + latest: true, + }, +])('preserves clone selection in JSON mode: %j', async ({argv, interactive, choice, repoUrl, latest}) => { + vi.mocked(terminalSupportsPrompting).mockReturnValue(interactive) + vi.mocked(promptAIInstruction).mockResolvedValue(null) + if (choice) vi.mocked(renderSelectPrompt).mockResolvedValue(choice) + await inTemporaryDirectory(async (directory) => { + await withCapturedStandardStreams(async ({stdout}) => { + await runWithCommandEventsForCommand(['--json'], () => run(['example', '--path', directory, '--json', ...argv])) + expect(JSON.parse(stdout())).toMatchObject({status: 'success', latest}) + }) + expect(downloadGitRepository).toHaveBeenCalledWith( + expect.objectContaining({repoUrl, destination: joinPath(directory, 'example')}), ) - }) - - test('generates a name without prompting when --no-input is set', async () => { - vi.mocked(generateRandomNameForSubdirectory).mockResolvedValue('new-theme') - - await run(['--no-input'], '') - - expect(renderTextPrompt).not.toHaveBeenCalled() - expect(cloneLatestStableSkeletonTheme).toHaveBeenCalledWith(expect.stringContaining('new-theme')) - expect(promptAIInstruction).not.toHaveBeenCalled() + if (choice) expect(renderSelectPrompt).toHaveBeenCalledOnce() + else expect(renderSelectPrompt).not.toHaveBeenCalled() + if (argv.some((flag) => flag === '--no-input') || !interactive) expect(promptAIInstruction).not.toHaveBeenCalled() }) }) diff --git a/packages/theme/src/cli/commands/theme/init.ts b/packages/theme/src/cli/commands/theme/init.ts index 027ecafd014..f95b0965d87 100644 --- a/packages/theme/src/cli/commands/theme/init.ts +++ b/packages/theme/src/cli/commands/theme/init.ts @@ -8,10 +8,14 @@ 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 {AbortSilentError, CancelExecution} from '@shopify/cli-kit/node/error' +import {emitCommandEvent} from '@shopify/cli-kit/node/command-events' 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 {renderSelectPrompt, renderTextPrompt} from '@shopify/cli-kit/node/ui' +import {renderError, renderSelectPrompt, renderTextPrompt} from '@shopify/cli-kit/node/ui' import {joinPath} from '@shopify/cli-kit/node/path' import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' import {InferredArgs, InferredFlags} from '@oclif/core/interfaces' @@ -21,6 +25,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. @@ -44,6 +52,7 @@ export default class Init extends ThemeCommand { static flags = { ...globalFlags, + ...jsonFlag, path: themeFlags.path, 'clone-url': Flags.string({ char: 'u', @@ -64,8 +73,8 @@ export default class Init extends ThemeCommand { const name = args.name ?? (await this.promptName(flags.path, flags['no-input'])) const repoUrl = flags['clone-url'] const destination = joinPath(flags.path, name) - let latestRelease = flags.latest + let latestRelease = flags.latest if (!latestRelease && repoUrl === SKELETON_THEME_URL) { latestRelease = flags['no-input'] || @@ -79,24 +88,33 @@ export default class Init extends ThemeCommand { defaultValue: 'stable', })) === 'stable' } - - if (latestRelease && repoUrl === SKELETON_THEME_URL) { - await cloneLatestStableSkeletonTheme(destination) - } else if (latestRelease) { - await cloneRepoAndCheckoutLatestTag(repoUrl, destination) - } else { - await cloneRepo(repoUrl, destination) - } - - if (flags['no-input'] || !terminalSupportsPrompting()) return - - const aiInstruction = await promptAIInstruction() - - if (!aiInstruction) { - return + const cloneRepository = latestRelease ? cloneRepoAndCheckoutLatestTag : cloneRepo + const result = + latestRelease && repoUrl === SKELETON_THEME_URL + ? await cloneLatestStableSkeletonTheme(destination) + : await cloneRepository(repoUrl, destination) + const format = flags.json ? 'json' : 'text' + + if (!flags['no-input'] && terminalSupportsPrompting()) { + const aiInstruction = await promptAIInstruction() + if (aiInstruction) { + result.aiInstructions = aiInstruction + try { + const instructions = await createAIInstructions(destination, aiInstruction) + result.instructionFiles = instructions.files + renderAIInstructionsWarning(instructions.copiedFiles) + } catch (error) { + if (error instanceof AbortSilentError || error instanceof CancelExecution) throw error + result.status = 'partial' + result.reason = error instanceof Error ? error.message : String(error) + process.exitCode = 1 + if (flags.json) emitCommandEvent({type: 'diagnostic', level: 'error', message: result.reason}) + else renderError({body: result.reason}) + } + } } - await createAIInstructions(destination, aiInstruction) + renderThemeInitResult(result, format) } async promptName(directory: string, inputDisabled = false) { diff --git a/packages/theme/src/cli/services/init.test.ts b/packages/theme/src/cli/services/init.test.ts index 56107523351..948f4b00a46 100644 --- a/packages/theme/src/cli/services/init.test.ts +++ b/packages/theme/src/cli/services/init.test.ts @@ -4,296 +4,147 @@ import { cloneRepo, createAIInstructions, createAIInstructionFiles, + SKELETON_THEME_URL, } from './init.js' -import {describe, expect, vi, test, beforeEach} from 'vitest' +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 {getLatestGitHubRelease, type GithubRelease} from '@shopify/cli-kit/node/github' +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/github') -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 = false - - // 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') +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('cloneLatestStableSkeletonTheme()', () => { - beforeEach(() => { - vi.mocked(fileExists).mockResolvedValue(true) - vi.mocked(joinPath).mockImplementation((...paths) => paths.join('/')) +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('') + }) }) +}) - test('clones the latest published stable release and removes Skeleton theme development files', async () => { - vi.mocked(getLatestGitHubRelease).mockResolvedValue({tag_name: 'v1.0.0'} as GithubRelease) - - await cloneLatestStableSkeletonTheme('destination') - - expect(getLatestGitHubRelease).toHaveBeenCalledWith('Shopify', 'skeleton-theme', {filter: expect.any(Function)}) - const filter = vi.mocked(getLatestGitHubRelease).mock.calls[0]![2]!.filter - expect(filter({draft: false, prerelease: false} as GithubRelease)).toBe(true) - expect(filter({draft: false, prerelease: true} as GithubRelease)).toBe(false) - expect(filter({draft: true, prerelease: false} as GithubRelease)).toBe(false) +test('returns stable Skeleton clone metadata and removes development files', async () => { + vi.mocked(getLatestGitHubRelease).mockResolvedValue({tag_name: 'v1.0.0'} as GithubRelease) + await inTemporaryDirectory(async (destination) => { + await Promise.all(['.github', '.cursor', '.claude', '.git'].map((name) => mkdir(joinPath(destination, name)))) + await withCapturedStandardStreams(async () => { + const result = await runWithCommandEventsForCommand(['--json'], () => cloneLatestStableSkeletonTheme(destination)) + expect(result).toEqual({ + path: destination, + repoUrl: SKELETON_THEME_URL, + latest: true, + aiInstructions: null, + instructionFiles: [], + }) + }) expect(downloadGitRepository).toHaveBeenCalledWith({ - repoUrl: 'https://github.com/Shopify/skeleton-theme.git#v1.0.0', - destination: 'destination', + repoUrl: `${SKELETON_THEME_URL}#v1.0.0`, + destination, latestTag: undefined, shallow: true, }) - expect(rmdir).toHaveBeenCalledWith('destination/.git') - }) - - test('fails when no stable release is available', async () => { - vi.mocked(getLatestGitHubRelease).mockResolvedValue(undefined as unknown as GithubRelease) + for (const name of ['.github', '.cursor', '.claude', '.git']) { + // eslint-disable-next-line no-await-in-loop + await expect(fileExists(joinPath(destination, name))).resolves.toBe(false) + } + }) + const filter = vi.mocked(getLatestGitHubRelease).mock.calls[0]![2]!.filter + expect(filter({draft: false, prerelease: false} as GithubRelease)).toBe(true) + expect(filter({draft: false, prerelease: true} as GithubRelease)).toBe(false) + expect(filter({draft: true, prerelease: false} as GithubRelease)).toBe(false) +}) - await expect(cloneLatestStableSkeletonTheme('destination')).rejects.toThrow( +test('fails without cloning when no stable Skeleton release exists', async () => { + vi.mocked(getLatestGitHubRelease).mockResolvedValue(undefined as unknown as GithubRelease) + await inTemporaryDirectory(async (destination) => { + await expect(cloneLatestStableSkeletonTheme(destination)).rejects.toThrow( "Couldn't find a stable Skeleton theme release", ) expect(downloadGitRepository).not.toHaveBeenCalled() }) }) - -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') - }) -}) - -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') - }) -}) diff --git a/packages/theme/src/cli/services/init.ts b/packages/theme/src/cli/services/init.ts index b5093202225..b3129944c71 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' @@ -18,25 +19,24 @@ 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) } export async function cloneLatestStableSkeletonTheme(destination: string) { const release = await getLatestGitHubRelease('Shopify', 'skeleton-theme', { filter: (release) => !release.draft && !release.prerelease, }) - if (!release) { - throw new AbortError("Couldn't find a stable Skeleton theme release") - } + if (!release) throw new AbortError("Couldn't find a stable Skeleton theme release") - await downloadRepository(`${SKELETON_THEME_URL}#${release.tag_name}`, destination) + const result = await downloadRepository(`${SKELETON_THEME_URL}#${release.tag_name}`, destination) + return {...result, repoUrl: SKELETON_THEME_URL, latest: 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}`, @@ -45,9 +45,9 @@ async function downloadRepository(repoUrl: string, destination: string, latestTa repoUrl, destination, latestTag, + // Fetch tags and history when selecting the latest release. shallow: !latestTag, }) - await removeGitRemote(destination) if (repoUrl.split('#')[0] === SKELETON_THEME_URL) { @@ -61,6 +61,8 @@ async function downloadRepository(repoUrl: string, destination: string, latestTa }, }, ]) + + return {path: destination, repoUrl, latest: latestTag ?? false, aiInstructions: null, instructionFiles: []} } async function removeDirectory(path: string) { @@ -85,7 +87,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([ { @@ -116,10 +119,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) { @@ -130,14 +140,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..76d4418cf0b --- /dev/null +++ b/packages/theme/src/cli/services/init/result.ts @@ -0,0 +1,19 @@ +import {themeInitJsonOutputSchema, projectThemeInitResult, type ThemeInitResult} from './types.js' +import {outputResult} from '@shopify/cli-kit/node/output' +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(projectThemeInitResult(result))) +} + +export function renderAIInstructionsWarning(copiedFiles: string[]): 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.` + + renderWarning({headline, body}) +} diff --git a/packages/theme/src/cli/services/init/types.test.ts b/packages/theme/src/cli/services/init/types.test.ts new file mode 100644 index 00000000000..3f772c12865 --- /dev/null +++ b/packages/theme/src/cli/services/init/types.test.ts @@ -0,0 +1,17 @@ +import {projectThemeInitResult, themeInitJsonOutputSchema} from './types.js' +import {jsonSchemaValidate, normaliseJsonSchema} from '@shopify/cli-kit/node/json-schema' +import {expect, test} from 'vitest' + +test('publishes a resolvable JSON schema for results with instruction files', async () => { + const schema = await normaliseJsonSchema(JSON.stringify(themeInitJsonOutputSchema.jsonSchema)) + const result = projectThemeInitResult({ + path: '.', + repoUrl: 'https://example.com/theme.git', + latest: true, + aiInstructions: 'claude', + instructionFiles: ['AGENTS.md'], + }) + + expect(jsonSchemaValidate(result, schema, 'fail').state).toBe('ok') + expect(jsonSchemaValidate({...result, instructionFilePaths: [42]}, schema, 'fail').state).toBe('error') +}) 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..45675811077 --- /dev/null +++ b/packages/theme/src/cli/services/init/types.ts @@ -0,0 +1,52 @@ +import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' +import {isAbsolutePath} from '@shopify/cli-kit/node/path' +// Native JSON paths must preserve Windows separators instead of pathe normalization. +// eslint-disable-next-line no-restricted-imports +import {resolve as resolvePath} from 'node:path' + +const AIInstructionsSchema = zod.enum(['all', 'github', 'cursor', 'claude']).nullable() +const AbsolutePathSchema = zod.string().refine(isAbsolutePath).describe('An absolute native filesystem path.') + +export const themeInitJsonOutputSchema = defineJsonOutputSchema({ + name: 'ThemeInitResult', + definitions: {AbsolutePath: AbsolutePathSchema}, + schema: zod + .object({ + status: zod.enum(['success', 'partial']), + changed: zod.literal(true), + directory: AbsolutePathSchema, + repoUrl: zod.string().min(1).describe('The source Git remote (including HTTPS, SSH, or SCP-style Git URLs).'), + latest: zod.boolean(), + aiInstructions: AIInstructionsSchema, + instructionFilePaths: zod + .array(AbsolutePathSchema) + .nullable() + .describe('Created instruction files, or null when setup failed before their completion could be established.'), + reason: zod.string().nullable().describe('The reason for incomplete setup, or null when setup completed.'), + }) + .strict(), +}) + +export interface ThemeInitResult { + path: string + repoUrl: string + latest: boolean + aiInstructions: zod.infer + instructionFiles: string[] + status?: 'success' | 'partial' + reason?: string +} + +export function projectThemeInitResult(result: ThemeInitResult) { + return { + status: result.status ?? 'success', + changed: true as const, + directory: resolvePath(result.path), + repoUrl: result.repoUrl, + latest: result.latest, + aiInstructions: result.aiInstructions, + instructionFilePaths: result.status === 'partial' ? null : result.instructionFiles.map((path) => resolvePath(path)), + reason: result.reason ?? null, + } +}