From b3a15caeb1af18eb07bb4dda0b91362e8f6dd40a Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Mon, 28 Sep 2026 11:47:34 +0200 Subject: [PATCH 1/6] Add typed JSON output to theme init --- packages/cli/README.md | 58 +++ packages/cli/oclif.manifest.json | 11 +- .../rules/json-output-command-exceptions.js | 1 - .../theme/src/cli/commands/theme/init.test.ts | 224 ++++++----- packages/theme/src/cli/commands/theme/init.ts | 44 ++- packages/theme/src/cli/services/init.test.ts | 363 ++++-------------- packages/theme/src/cli/services/init.ts | 41 +- .../theme/src/cli/services/init/result.ts | 24 ++ packages/theme/src/cli/services/init/types.ts | 15 + 9 files changed, 371 insertions(+), 410 deletions(-) create mode 100644 packages/theme/src/cli/services/init/result.ts create mode 100644 packages/theme/src/cli/services/init/types.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 49035cf0ada..0dd19cca1f0 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,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 a0885c6f9d5..c35b4f31ec0 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 \"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": { @@ -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..2ea8db2cb56 100644 --- a/packages/theme/src/cli/commands/theme/init.test.ts +++ b/packages/theme/src/cli/commands/theme/init.test.ts @@ -1,113 +1,151 @@ 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' +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('../../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(), -})) -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/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') -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('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.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(cloneLatestStableSkeletonTheme).toHaveBeenCalledOnce() - expect(cloneRepo).not.toHaveBeenCalled() - }) - - 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.each([['--latest'], ['--no-input']])('uses the stable release with %s', async (flag) => { - await run([flag]) - - expect(renderSelectPrompt).not.toHaveBeenCalled() - expect(cloneLatestStableSkeletonTheme).toHaveBeenCalledOnce() + expect(createAIInstructions).not.toHaveBeenCalled() }) +}) - 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('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('keeps the default branch for an interactive custom repository', async () => { - await run(['--clone-url=https://github.com/Shopify/dawn.git']) - - expect(renderSelectPrompt).not.toHaveBeenCalled() - expect(cloneRepo).toHaveBeenCalledWith('https://github.com/Shopify/dawn.git', expect.any(String)) +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('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() +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 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('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('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() - }) +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 027ecafd014..bbbd4eca4ba 100644 --- a/packages/theme/src/cli/commands/theme/init.ts +++ b/packages/theme/src/cli/commands/theme/init.ts @@ -8,8 +8,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 {renderSelectPrompt, renderTextPrompt} from '@shopify/cli-kit/node/ui' import {joinPath} from '@shopify/cli-kit/node/path' @@ -21,6 +23,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 +50,7 @@ export default class Init extends ThemeCommand { static flags = { ...globalFlags, + ...jsonFlag, path: themeFlags.path, 'clone-url': Flags.string({ char: 'u', @@ -64,8 +71,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 +86,25 @@ 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 result = + latestRelease && repoUrl === SKELETON_THEME_URL + ? await cloneLatestStableSkeletonTheme(destination) + : latestRelease + ? await cloneRepoAndCheckoutLatestTag(repoUrl, destination) + : await cloneRepo(repoUrl, destination) + const format = flags.json ? 'json' : 'text' + + if (!flags['no-input'] && 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, inputDisabled = false) { diff --git a/packages/theme/src/cli/services/init.test.ts b/packages/theme/src/cli/services/init.test.ts index 56107523351..8308b7de300 100644 --- a/packages/theme/src/cli/services/init.test.ts +++ b/packages/theme/src/cli/services/init.test.ts @@ -1,299 +1,106 @@ import { cloneRepoAndCheckoutLatestTag, - cloneLatestStableSkeletonTheme, 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') - }) -}) - -describe('cloneLatestStableSkeletonTheme()', () => { - beforeEach(() => { - vi.mocked(fileExists).mockResolvedValue(true) - vi.mocked(joinPath).mockImplementation((...paths) => paths.join('/')) - }) - - 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) - expect(downloadGitRepository).toHaveBeenCalledWith({ - repoUrl: 'https://github.com/Shopify/skeleton-theme.git#v1.0.0', - destination: '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) - - 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') +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 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..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 From 92a888ab24154dd4428edddf594d764897790c53 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:01:21 +0200 Subject: [PATCH 2/6] Normalize theme initialization JSON and preserve partial setup --- .../theme/src/cli/commands/theme/init.test.ts | 61 +++++++++++++++---- packages/theme/src/cli/commands/theme/init.ts | 19 ++++-- .../theme/src/cli/services/init/result.ts | 4 +- packages/theme/src/cli/services/init/types.ts | 51 +++++++++++++--- 4 files changed, 108 insertions(+), 27 deletions(-) diff --git a/packages/theme/src/cli/commands/theme/init.test.ts b/packages/theme/src/cli/commands/theme/init.test.ts index 2ea8db2cb56..232be9b621d 100644 --- a/packages/theme/src/cli/commands/theme/init.test.ts +++ b/packages/theme/src/cli/commands/theme/init.test.ts @@ -47,11 +47,14 @@ test.each([true, false])('emits the cloned theme after skipped AI setup, interac await withCapturedStandardStreams(async ({stdout, stderr}) => { await runWithCommandEventsForCommand(['--json'], () => run(['example', '--path', directory, '--json'])) expect(JSON.parse(stdout())).toEqual({ - path: joinPath(directory, 'example'), + status: 'success', + changed: true, + directory: joinPath(directory, 'example'), repoUrl: SKELETON_THEME_URL, latest: false, aiInstructions: null, - instructionFiles: [], + instructionFilePaths: [], + reason: null, }) expect( stderr() @@ -81,11 +84,14 @@ test('preserves the name prompt in JSON mode and waits for AI instructions', asy run(['--path', directory, '--latest', '--clone-url', 'https://example.com/theme.git', '--json']), ) expect(JSON.parse(stdout())).toEqual({ - path, + status: 'success', + changed: true, + directory: path, repoUrl: 'https://example.com/theme.git', latest: true, aiInstructions: 'claude', - instructionFiles, + instructionFilePaths: instructionFiles, + reason: null, }) expect( stderr() @@ -101,16 +107,30 @@ test('preserves the name prompt in JSON mode and waits for AI instructions', asy }) }) -test('does not emit a success result when AI setup fails', async () => { +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}) => { - await expect( - runWithCommandEventsForCommand(['--json'], () => run(['example', '--path', directory, '--json'])), - ).rejects.toThrow('Failed to create AI instructions') - expect(stdout()).toBe('') + 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: joinPath(directory, 'example'), + repoUrl: SKELETON_THEME_URL, + latest: false, + 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 + } }) }) }) @@ -141,11 +161,28 @@ test('exposes the schema and rejects invalid instruction choices', () => { expect(Init.description).toContain('--json-schema') expect(() => themeInitJsonOutputSchema.validate({ - path: '/theme', + status: 'success', + changed: true, + directory: '/theme', + reason: null, repoUrl: SKELETON_THEME_URL, latest: false, aiInstructions: 'invalid', - instructionFiles: [], + instructionFilePaths: [], }), ).toThrow() }) + +test('requires a name without prompting in non-interactive JSON mode', async () => { + vi.mocked(terminalSupportsPrompting).mockReturnValue(false) + await inTemporaryDirectory(async (directory) => { + await withCapturedStandardStreams(async ({stdout}) => { + await expect(run(['--path', directory, '--json'])).rejects.toThrow( + 'A theme name is required in non-interactive mode.', + ) + expect(stdout()).toBe('') + }) + expect(renderTextPrompt).not.toHaveBeenCalled() + expect(downloadGitRepository).not.toHaveBeenCalled() + }) +}) diff --git a/packages/theme/src/cli/commands/theme/init.ts b/packages/theme/src/cli/commands/theme/init.ts index bbbd4eca4ba..04dd6b4b6ce 100644 --- a/packages/theme/src/cli/commands/theme/init.ts +++ b/packages/theme/src/cli/commands/theme/init.ts @@ -10,10 +10,12 @@ 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, 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' @@ -97,10 +99,19 @@ export default class Init extends ThemeCommand { if (!flags['no-input'] && terminalSupportsPrompting()) { const aiInstruction = await promptAIInstruction() if (aiInstruction) { - const instructions = await createAIInstructions(destination, aiInstruction) result.aiInstructions = aiInstruction - result.instructionFiles = instructions.files - renderAIInstructionsWarning(instructions.copiedFiles, format) + try { + const instructions = await createAIInstructions(destination, aiInstruction) + result.instructionFiles = instructions.files + renderAIInstructionsWarning(instructions.copiedFiles, format) + } 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}) + } } } diff --git a/packages/theme/src/cli/services/init/result.ts b/packages/theme/src/cli/services/init/result.ts index 453e32e02b0..24b4b9535bb 100644 --- a/packages/theme/src/cli/services/init/result.ts +++ b/packages/theme/src/cli/services/init/result.ts @@ -1,11 +1,11 @@ -import {themeInitJsonOutputSchema, type ThemeInitResult} from './types.js' +import {themeInitJsonOutputSchema, projectThemeInitResult, 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)) + if (format === 'json') outputResult(themeInitJsonOutputSchema.encode(projectThemeInitResult(result))) } export function renderAIInstructionsWarning(copiedFiles: string[], format: 'text' | 'json'): void { diff --git a/packages/theme/src/cli/services/init/types.ts b/packages/theme/src/cli/services/init/types.ts index 389d365d77b..bb51ef8fa43 100644 --- a/packages/theme/src/cli/services/init/types.ts +++ b/packages/theme/src/cli/services/init/types.ts @@ -1,15 +1,48 @@ -import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' import {zod} from '@shopify/cli-kit/node/schema' +import {isAbsolutePath, resolvePath} from '@shopify/cli-kit/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', - 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()), - }), + 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 type ThemeInitResult = InferJsonOutputSchema +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, + } +} From 170f77e523f811c2507f540f87f633fab5e38655 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:16:44 +0200 Subject: [PATCH 3/6] Refresh theme init JSON schema documentation --- packages/cli/README.md | 52 ++++++++++++++++++++++++++------ packages/cli/oclif.manifest.json | 2 +- 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 0dd19cca1f0..0e81c0e0023 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14252,11 +14252,25 @@ DESCRIPTION { "type": "object", "properties": { - "path": { - "type": "string" + "status": { + "type": "string", + "enum": [ + "success", + "partial" + ] + }, + "changed": { + "type": "boolean", + "const": true + }, + "directory": { + "type": "string", + "description": "An absolute native filesystem path." }, "repoUrl": { - "type": "string" + "type": "string", + "minLength": 1, + "description": "The source Git remote (including HTTPS, SSH, or SCP-style Git URLs)." }, "latest": { "type": "boolean" @@ -14277,19 +14291,37 @@ DESCRIPTION } ] }, - "instructionFiles": { - "type": "array", - "items": { - "type": "string" - } + "instructionFilePaths": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/definitions/ThemeInitResult/properties/directory" + } + }, + { + "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": [ - "path", + "status", + "changed", + "directory", "repoUrl", "latest", "aiInstructions", - "instructionFiles" + "instructionFilePaths", + "reason" ], "additionalProperties": false, "title": "ThemeInitResult", diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index c35b4f31ec0..e0338df0f32 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 \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```", + "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 \"type\": \"string\",\n \"description\": \"An absolute native filesystem path.\"\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/ThemeInitResult/properties/directory\"\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 \"$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": { From a4509d4e88fd24d6d556a5215e3bf253eb1224f5 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:17:15 +0200 Subject: [PATCH 4/6] Refresh generated theme init command docs --- .../generated/generated_docs_data_v2.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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": { From fc25508c770803bd83c9d86e4108d85e35a7d705 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 15:55:30 +0200 Subject: [PATCH 5/6] Emit native absolute paths at JSON output boundaries --- packages/theme/src/cli/commands/theme/init.test.ts | 13 ++++++++----- packages/theme/src/cli/services/init/types.ts | 5 ++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/theme/src/cli/commands/theme/init.test.ts b/packages/theme/src/cli/commands/theme/init.test.ts index 232be9b621d..127323197d3 100644 --- a/packages/theme/src/cli/commands/theme/init.test.ts +++ b/packages/theme/src/cli/commands/theme/init.test.ts @@ -11,6 +11,9 @@ 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' +// 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/ui', async (importOriginal) => ({ @@ -49,7 +52,7 @@ test.each([true, false])('emits the cloned theme after skipped AI setup, interac expect(JSON.parse(stdout())).toEqual({ status: 'success', changed: true, - directory: joinPath(directory, 'example'), + directory: nativePath(directory, 'example'), repoUrl: SKELETON_THEME_URL, latest: false, aiInstructions: null, @@ -86,11 +89,11 @@ test('preserves the name prompt in JSON mode and waits for AI instructions', asy expect(JSON.parse(stdout())).toEqual({ status: 'success', changed: true, - directory: path, + directory: nativePath(path), repoUrl: 'https://example.com/theme.git', latest: true, aiInstructions: 'claude', - instructionFilePaths: instructionFiles, + instructionFilePaths: instructionFiles.map((path) => nativePath(path)), reason: null, }) expect( @@ -119,7 +122,7 @@ test('preserves the cloned project when AI setup fails', async () => { expect(JSON.parse(stdout())).toEqual({ status: 'partial', changed: true, - directory: joinPath(directory, 'example'), + directory: nativePath(directory, 'example'), repoUrl: SKELETON_THEME_URL, latest: false, aiInstructions: 'cursor', @@ -163,7 +166,7 @@ test('exposes the schema and rejects invalid instruction choices', () => { themeInitJsonOutputSchema.validate({ status: 'success', changed: true, - directory: '/theme', + directory: nativePath('/theme'), reason: null, repoUrl: SKELETON_THEME_URL, latest: false, diff --git a/packages/theme/src/cli/services/init/types.ts b/packages/theme/src/cli/services/init/types.ts index bb51ef8fa43..ebf8ae5ce6f 100644 --- a/packages/theme/src/cli/services/init/types.ts +++ b/packages/theme/src/cli/services/init/types.ts @@ -1,6 +1,9 @@ import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' import {zod} from '@shopify/cli-kit/node/schema' -import {isAbsolutePath, resolvePath} from '@shopify/cli-kit/node/path' +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.') From 519f93c89b747637ecaad1892d16cf9c0ec08d46 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 8 Oct 2026 10:33:51 +0200 Subject: [PATCH 6/6] Publish a resolvable theme init output schema --- packages/cli/README.md | 11 ++- packages/cli/oclif.manifest.json | 2 +- .../theme/src/cli/commands/theme/init.test.ts | 72 ++++++++++++++++--- packages/theme/src/cli/commands/theme/init.ts | 7 +- packages/theme/src/cli/services/init.test.ts | 44 ++++++++++++ .../theme/src/cli/services/init/result.ts | 9 +-- .../theme/src/cli/services/init/types.test.ts | 17 +++++ packages/theme/src/cli/services/init/types.ts | 1 + 8 files changed, 138 insertions(+), 25 deletions(-) create mode 100644 packages/theme/src/cli/services/init/types.test.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 0e81c0e0023..37e938ccf4c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14264,8 +14264,7 @@ DESCRIPTION "const": true }, "directory": { - "type": "string", - "description": "An absolute native filesystem path." + "$ref": "#/definitions/AbsolutePath" }, "repoUrl": { "type": "string", @@ -14296,7 +14295,7 @@ DESCRIPTION { "type": "array", "items": { - "$ref": "#/definitions/ThemeInitResult/properties/directory" + "$ref": "#/definitions/AbsolutePath" } }, { @@ -14325,6 +14324,12 @@ DESCRIPTION ], "additionalProperties": false, "title": "ThemeInitResult", + "definitions": { + "AbsolutePath": { + "type": "string", + "description": "An absolute native filesystem path." + } + }, "$schema": "http://json-schema.org/draft-07/schema#" } ``` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index e0338df0f32..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 \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 \"type\": \"string\",\n \"description\": \"An absolute native filesystem path.\"\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/ThemeInitResult/properties/directory\"\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 \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\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": { diff --git a/packages/theme/src/cli/commands/theme/init.test.ts b/packages/theme/src/cli/commands/theme/init.test.ts index 127323197d3..5ef44e887ce 100644 --- a/packages/theme/src/cli/commands/theme/init.test.ts +++ b/packages/theme/src/cli/commands/theme/init.test.ts @@ -9,15 +9,20 @@ 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 {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 (importOriginal) => ({ ...(await importOriginal()), + renderSelectPrompt: vi.fn(async () => 'stable'), renderTextPrompt: vi.fn(), renderWarning: vi.fn(), })) @@ -54,7 +59,7 @@ test.each([true, false])('emits the cloned theme after skipped AI setup, interac changed: true, directory: nativePath(directory, 'example'), repoUrl: SKELETON_THEME_URL, - latest: false, + latest: true, aiInstructions: null, instructionFilePaths: [], reason: null, @@ -71,6 +76,9 @@ test.each([true, false])('emits the cloned theme after skipped AI setup, interac }) 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') @@ -124,7 +132,7 @@ test('preserves the cloned project when AI setup fails', async () => { changed: true, directory: nativePath(directory, 'example'), repoUrl: SKELETON_THEME_URL, - latest: false, + latest: true, aiInstructions: 'cursor', instructionFilePaths: null, reason: 'Failed to create AI instructions', @@ -151,7 +159,7 @@ test('does not emit a result when cloning fails', async () => { }) test('keeps the copy warning in the text presenter', () => { - renderAIInstructionsWarning(['CLAUDE.md'], 'text') + 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.", @@ -176,16 +184,60 @@ test('exposes the schema and rejects invalid instruction choices', () => { ).toThrow() }) -test('requires a name without prompting in non-interactive JSON mode', async () => { +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 expect(run(['--path', directory, '--json'])).rejects.toThrow( - 'A theme name is required in non-interactive mode.', - ) - expect(stdout()).toBe('') + 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(downloadGitRepository).not.toHaveBeenCalled() + expect(renderSelectPrompt).not.toHaveBeenCalled() + expect(promptAIInstruction).not.toHaveBeenCalled() + }) +}) + +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')}), + ) + 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 04dd6b4b6ce..f95b0965d87 100644 --- a/packages/theme/src/cli/commands/theme/init.ts +++ b/packages/theme/src/cli/commands/theme/init.ts @@ -88,12 +88,11 @@ export default class Init extends ThemeCommand { defaultValue: 'stable', })) === 'stable' } + const cloneRepository = latestRelease ? cloneRepoAndCheckoutLatestTag : cloneRepo const result = latestRelease && repoUrl === SKELETON_THEME_URL ? await cloneLatestStableSkeletonTheme(destination) - : latestRelease - ? await cloneRepoAndCheckoutLatestTag(repoUrl, destination) - : await cloneRepo(repoUrl, destination) + : await cloneRepository(repoUrl, destination) const format = flags.json ? 'json' : 'text' if (!flags['no-input'] && terminalSupportsPrompting()) { @@ -103,7 +102,7 @@ export default class Init extends ThemeCommand { try { const instructions = await createAIInstructions(destination, aiInstruction) result.instructionFiles = instructions.files - renderAIInstructionsWarning(instructions.copiedFiles, format) + renderAIInstructionsWarning(instructions.copiedFiles) } catch (error) { if (error instanceof AbortSilentError || error instanceof CancelExecution) throw error result.status = 'partial' diff --git a/packages/theme/src/cli/services/init.test.ts b/packages/theme/src/cli/services/init.test.ts index 8308b7de300..948f4b00a46 100644 --- a/packages/theme/src/cli/services/init.test.ts +++ b/packages/theme/src/cli/services/init.test.ts @@ -1,5 +1,6 @@ import { cloneRepoAndCheckoutLatestTag, + cloneLatestStableSkeletonTheme, cloneRepo, createAIInstructions, createAIInstructionFiles, @@ -9,10 +10,12 @@ import {describe, expect, vi, test} from 'vitest' import {downloadGitRepository, removeGitRemote} from '@shopify/cli-kit/node/git' 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') describe.each([cloneRepo, cloneRepoAndCheckoutLatestTag])('%s', (clone) => { test.each([SKELETON_THEME_URL, 'https://github.com/Shopify/dawn.git'])('clones and cleans up %s', async (repoUrl) => { @@ -104,3 +107,44 @@ test('propagates clone failures', async () => { }) }) }) + +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: `${SKELETON_THEME_URL}#v1.0.0`, + destination, + latestTag: undefined, + shallow: true, + }) + 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) +}) + +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() + }) +}) diff --git a/packages/theme/src/cli/services/init/result.ts b/packages/theme/src/cli/services/init/result.ts index 24b4b9535bb..76d4418cf0b 100644 --- a/packages/theme/src/cli/services/init/result.ts +++ b/packages/theme/src/cli/services/init/result.ts @@ -1,6 +1,5 @@ import {themeInitJsonOutputSchema, projectThemeInitResult, 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 { @@ -8,7 +7,7 @@ export function renderThemeInitResult(result: ThemeInitResult, format: 'text' | if (format === 'json') outputResult(themeInitJsonOutputSchema.encode(projectThemeInitResult(result))) } -export function renderAIInstructionsWarning(copiedFiles: string[], format: 'text' | 'json'): void { +export function renderAIInstructionsWarning(copiedFiles: string[]): void { if (copiedFiles.length === 0) return const headline = 'Files created instead of symlinks.' @@ -16,9 +15,5 @@ export function renderAIInstructionsWarning(copiedFiles: string[], format: 'text ', ', )}, 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}) - } + 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 index ebf8ae5ce6f..45675811077 100644 --- a/packages/theme/src/cli/services/init/types.ts +++ b/packages/theme/src/cli/services/init/types.ts @@ -10,6 +10,7 @@ const AbsolutePathSchema = zod.string().refine(isAbsolutePath).describe('An abso export const themeInitJsonOutputSchema = defineJsonOutputSchema({ name: 'ThemeInitResult', + definitions: {AbsolutePath: AbsolutePathSchema}, schema: zod .object({ status: zod.enum(['success', 'partial']),