From 59b82bbd43431412a99b29e26d9a6ff54f69c36f Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Mon, 28 Sep 2026 11:45:50 +0200 Subject: [PATCH 1/5] Add typed JSON output to theme package --- packages/cli/oclif.manifest.json | 11 +++- .../rules/json-output-command-exceptions.js | 1 - .../src/cli/commands/theme/package.test.ts | 66 +++++++++++++++++++ .../theme/src/cli/commands/theme/package.ts | 12 +++- .../theme/src/cli/services/package.test.ts | 21 +++--- packages/theme/src/cli/services/package.ts | 10 ++- .../theme/src/cli/services/package/result.ts | 13 ++++ .../theme/src/cli/services/package/types.ts | 9 +++ 8 files changed, 121 insertions(+), 22 deletions(-) create mode 100644 packages/theme/src/cli/commands/theme/package.test.ts create mode 100644 packages/theme/src/cli/services/package/result.ts create mode 100644 packages/theme/src/cli/services/package/types.ts diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 6c5d203c11a..d4a2ba3fc78 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11849,7 +11849,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Packages your local theme files into a ZIP file that can be uploaded to Shopify.\n\n Only folders that match the \"default Shopify theme folder structure\" (https://shopify.dev/docs/storefronts/themes/tools/cli#directory-structure) are included in the package.\n\n The package includes the `listings` directory if present (required for multi-preset themes per \"Theme Store requirements\" (https://shopify.dev/docs/storefronts/themes/store/requirements#adding-presets-to-your-theme-zip-submission)).\n\n The ZIP file uses the name `theme_name-theme_version.zip`, based on parameters in your \"settings_schema.json\" (https://shopify.dev/docs/storefronts/themes/architecture/config/settings-schema-json) file.", + "description": "Packages your local theme files into a ZIP file that can be uploaded to Shopify.\n\n Only folders that match the \"default Shopify theme folder structure\" (https://shopify.dev/docs/storefronts/themes/tools/cli#directory-structure) are included in the package.\n\n The package includes the `listings` directory if present (required for multi-preset themes per \"Theme Store requirements\" (https://shopify.dev/docs/storefronts/themes/store/requirements#adding-presets-to-your-theme-zip-submission)).\n\n The ZIP file uses the name `theme_name-theme_version.zip`, based on parameters in your \"settings_schema.json\" (https://shopify.dev/docs/storefronts/themes/architecture/config/settings-schema-json) file.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePackageResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"path\"\n ],\n \"additionalProperties\": false,\n \"title\": \"ThemePackageResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Packages your local theme files into a ZIP file that can be uploaded to Shopify.\n\n Only folders that match the [default Shopify theme folder structure](https://shopify.dev/docs/storefronts/themes/tools/cli#directory-structure) are included in the package.\n\n The package includes the `listings` directory if present (required for multi-preset themes per [Theme Store requirements](https://shopify.dev/docs/storefronts/themes/store/requirements#adding-presets-to-your-theme-zip-submission)).\n\n The ZIP file uses the name `theme_name-theme_version.zip`, based on parameters in your [settings_schema.json](https://shopify.dev/docs/storefronts/themes/architecture/config/settings-schema-json) file.", "enableJsonFlag": false, "flags": { @@ -11861,6 +11861,15 @@ "name": "auth-alias", "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 74b0447484b..563f47da74a 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -27,7 +27,6 @@ const commandExceptions = [ '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', - 'packages/theme/src/cli/commands/theme/package.ts', // App Security commands, launched before adopting typed JSON output. Remove entries as they adopt it. 'packages/app/src/cli/commands/app/security/check.ts', diff --git a/packages/theme/src/cli/commands/theme/package.test.ts b/packages/theme/src/cli/commands/theme/package.test.ts new file mode 100644 index 00000000000..561f79fac66 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/package.test.ts @@ -0,0 +1,66 @@ +import Package from './package.js' +import {themePackageJsonOutputSchema} from '../../services/package/types.js' +import {renderThemePackageResult} from '../../services/package/result.js' +import {Config} from '@oclif/core' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {inTemporaryDirectory, mkdir, writeFile, fileExists} from '@shopify/cli-kit/node/fs' +import {joinPath, relativizePath, cwd} from '@shopify/cli-kit/node/path' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/cli-kit/node/analytics') +vi.mock('@shopify/cli-kit/node/metadata') +vi.mock('@shopify/cli-kit/node/environments') + +async function run(argv: string[]) { + const config = new Config({root: __dirname}) + await config.load() + await new Package(argv, config).run() +} + +test.each([true, false])('writes the package receipt to stdout with version=%s', async (includeVersion) => { + await inTemporaryDirectory(async (directory) => { + await mkdir(joinPath(directory, 'config')) + await writeFile( + joinPath(directory, 'config/settings_schema.json'), + JSON.stringify([{name: 'theme_info', theme_name: 'Dawn', ...(includeVersion ? {theme_version: '1.0'} : {})}]), + ) + const path = joinPath(directory, includeVersion ? 'Dawn-1.0.zip' : 'Dawn.zip') + + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(['--json'], () => run(['--path', directory, '--json'])) + expect(stdout()).toBe(`${themePackageJsonOutputSchema.encode({path})}\n`) + expect(stderr()).toBe('') + }) + await expect(fileExists(path)).resolves.toBe(true) + expect(renderSuccess).not.toHaveBeenCalled() + }) +}) + +test('preserves the text success banner', () => { + const path = joinPath(cwd(), 'Dawn.zip') + renderThemePackageResult({path}, 'text') + expect(renderSuccess).toHaveBeenCalledWith({ + body: ['Your local theme was packaged in', {filePath: relativizePath(path)}], + }) +}) + +test('does not emit a result when packaging fails', async () => { + await inTemporaryDirectory(async (directory) => { + await withCapturedStandardStreams(async ({stdout}) => { + await expect(run(['--path', directory, '--json'])).rejects.toThrow( + 'Provide a config/settings_schema.json to package your theme.', + ) + expect(stdout()).toBe('') + }) + }) +}) + +test('exposes the schema and rejects invalid package paths', () => { + expect(Package.jsonOutputSchema).toBe(themePackageJsonOutputSchema) + expect(Package.flags.json).toBeDefined() + expect(Package.description).toContain('--json-schema') + expect(() => themePackageJsonOutputSchema.validate({path: 42})).toThrow() +}) diff --git a/packages/theme/src/cli/commands/theme/package.ts b/packages/theme/src/cli/commands/theme/package.ts index 925d13602f4..a69ce14d0b6 100644 --- a/packages/theme/src/cli/commands/theme/package.ts +++ b/packages/theme/src/cli/commands/theme/package.ts @@ -1,11 +1,17 @@ import {themeFlags} from '../../flags.js' import ThemeCommand, {RequiredFlags} from '../../utilities/theme-command.js' import {packageTheme} from '../../services/package.js' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {themePackageJsonOutputSchema} from '../../services/package/types.js' +import {renderThemePackageResult} from '../../services/package/result.js' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {InferredFlags} from '@oclif/core/interfaces' type PackageFlags = InferredFlags export default class Package extends ThemeCommand { + static get jsonOutputSchema() { + return themePackageJsonOutputSchema + } + static summary = 'Package your theme into a .zip file, ready to upload to the Online Store.' static descriptionWithMarkdown = `Packages your local theme files into a ZIP file that can be uploaded to Shopify. @@ -20,12 +26,14 @@ export default class Package extends ThemeCommand { static flags = { ...globalFlags, + ...jsonFlag, path: themeFlags.path, } static multiEnvironmentsFlags: RequiredFlags = null async command(flags: PackageFlags) { - await packageTheme(flags.path) + const result = await packageTheme(flags.path) + renderThemePackageResult(result, flags.json ? 'json' : 'text') } } diff --git a/packages/theme/src/cli/services/package.test.ts b/packages/theme/src/cli/services/package.test.ts index edbf5bae0d3..3a2adaefa4e 100644 --- a/packages/theme/src/cli/services/package.test.ts +++ b/packages/theme/src/cli/services/package.test.ts @@ -33,7 +33,7 @@ describe('packageTheme', () => { ) // When - await packageTheme(inputDirectory) + const result = await packageTheme(inputDirectory) // Then const expectedOutputZipPath = joinPath(inputDirectory, 'Dawn-7.0.2.zip') @@ -42,9 +42,8 @@ describe('packageTheme', () => { const archiveEntries = await readArchiveFiles(expectedOutputZipPath) expect(themeRelativePaths.sort()).toEqual(archiveEntries.sort()) - expect(renderSuccess).toBeCalledWith({ - body: ['Your local theme was packaged in', {filePath: expectedOutputZipPath}], - }) + expect(result).toEqual({path: expectedOutputZipPath}) + expect(renderSuccess).not.toHaveBeenCalled() }) }) @@ -73,7 +72,7 @@ describe('packageTheme', () => { ) // When - await packageTheme(inputDirectory) + const result = await packageTheme(inputDirectory) // Then const expectedOutputZipPath = joinPath(inputDirectory, 'Dawn-7.0.2.zip') @@ -82,9 +81,8 @@ describe('packageTheme', () => { const archiveEntries = await readArchiveFiles(expectedOutputZipPath) expect(expectedThemeRelativePaths.sort()).toEqual(archiveEntries.sort()) - expect(renderSuccess).toBeCalledWith({ - body: ['Your local theme was packaged in', {filePath: expectedOutputZipPath}], - }) + expect(result).toEqual({path: expectedOutputZipPath}) + expect(renderSuccess).not.toHaveBeenCalled() }) }) @@ -105,7 +103,7 @@ describe('packageTheme', () => { await createSettingsSchema('[{"name": "theme_info", "theme_name": "Dawn"}]', inputDirectory) // When - await packageTheme(inputDirectory) + const result = await packageTheme(inputDirectory) // Then const expectedOutputZipPath = joinPath(inputDirectory, 'Dawn.zip') @@ -114,9 +112,8 @@ describe('packageTheme', () => { const archiveEntries = await readArchiveFiles(expectedOutputZipPath) expect(themeRelativePaths.sort()).toEqual(archiveEntries.sort()) - expect(renderSuccess).toBeCalledWith({ - body: ['Your local theme was packaged in', {filePath: expectedOutputZipPath}], - }) + expect(result).toEqual({path: expectedOutputZipPath}) + expect(renderSuccess).not.toHaveBeenCalled() }) }) diff --git a/packages/theme/src/cli/services/package.ts b/packages/theme/src/cli/services/package.ts index bfe10e0b60a..ba412c3a5aa 100644 --- a/packages/theme/src/cli/services/package.ts +++ b/packages/theme/src/cli/services/package.ts @@ -1,8 +1,8 @@ +import {type ThemePackageResult} from './package/types.js' import {zip} from '@shopify/cli-kit/node/archiver' import {fileExists, readFile} from '@shopify/cli-kit/node/fs' import {AbortError} from '@shopify/cli-kit/node/error' -import {renderSuccess} from '@shopify/cli-kit/node/ui' -import {resolvePath, relativizePath} from '@shopify/cli-kit/node/path' +import {resolvePath} from '@shopify/cli-kit/node/path' import {parseJSON} from '@shopify/theme-check-node' const themeFilesPattern = [ @@ -23,7 +23,7 @@ const themeFilesPattern = [ ].join('|') // package is a reserved word so the function needs to be named packageTheme -export async function packageTheme(inputDirectory: string) { +export async function packageTheme(inputDirectory: string): Promise { const packageName = await getThemePackageName(inputDirectory) const outputZipPath = `${inputDirectory}/${packageName}` @@ -35,9 +35,7 @@ export async function packageTheme(inputDirectory: string) { matchFilePattern, }) - renderSuccess({ - body: ['Your local theme was packaged in', {filePath: relativizePath(outputZipPath)}], - }) + return {path: outputZipPath} } async function getThemePackageName(inputDirectory: string) { diff --git a/packages/theme/src/cli/services/package/result.ts b/packages/theme/src/cli/services/package/result.ts new file mode 100644 index 00000000000..5ed5cefcc0b --- /dev/null +++ b/packages/theme/src/cli/services/package/result.ts @@ -0,0 +1,13 @@ +import {themePackageJsonOutputSchema, type ThemePackageResult} from './types.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {relativizePath} from '@shopify/cli-kit/node/path' +import {renderSuccess} from '@shopify/cli-kit/node/ui' + +export function renderThemePackageResult(result: ThemePackageResult, format: 'text' | 'json'): void { + if (format === 'json') { + outputResult(themePackageJsonOutputSchema.encode(result)) + return + } + + renderSuccess({body: ['Your local theme was packaged in', {filePath: relativizePath(result.path)}]}) +} diff --git a/packages/theme/src/cli/services/package/types.ts b/packages/theme/src/cli/services/package/types.ts new file mode 100644 index 00000000000..051c9480462 --- /dev/null +++ b/packages/theme/src/cli/services/package/types.ts @@ -0,0 +1,9 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +export const themePackageJsonOutputSchema = defineJsonOutputSchema({ + name: 'ThemePackageResult', + schema: zod.object({path: zod.string()}), +}) + +export type ThemePackageResult = InferJsonOutputSchema From a01b6cf94c7f6414b91ee8e408732fda75fb5ef9 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 13:59:14 +0200 Subject: [PATCH 2/5] Validate absolute theme package artifact paths --- packages/theme/src/cli/commands/theme/package.test.ts | 2 ++ packages/theme/src/cli/services/package.ts | 2 +- packages/theme/src/cli/services/package/types.ts | 5 ++++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/theme/src/cli/commands/theme/package.test.ts b/packages/theme/src/cli/commands/theme/package.test.ts index 561f79fac66..a6b41fa7836 100644 --- a/packages/theme/src/cli/commands/theme/package.test.ts +++ b/packages/theme/src/cli/commands/theme/package.test.ts @@ -63,4 +63,6 @@ test('exposes the schema and rejects invalid package paths', () => { expect(Package.flags.json).toBeDefined() expect(Package.description).toContain('--json-schema') expect(() => themePackageJsonOutputSchema.validate({path: 42})).toThrow() + expect(() => themePackageJsonOutputSchema.validate({path: 'Dawn.zip'})).toThrow() + expect(() => themePackageJsonOutputSchema.validate({path: joinPath(cwd(), 'Dawn.zip'), extra: true})).toThrow() }) diff --git a/packages/theme/src/cli/services/package.ts b/packages/theme/src/cli/services/package.ts index ba412c3a5aa..b8c670aa300 100644 --- a/packages/theme/src/cli/services/package.ts +++ b/packages/theme/src/cli/services/package.ts @@ -26,7 +26,7 @@ const themeFilesPattern = [ export async function packageTheme(inputDirectory: string): Promise { const packageName = await getThemePackageName(inputDirectory) - const outputZipPath = `${inputDirectory}/${packageName}` + const outputZipPath = resolvePath(inputDirectory, packageName) const matchFilePattern = `${inputDirectory}/(${themeFilesPattern})` await zip({ diff --git a/packages/theme/src/cli/services/package/types.ts b/packages/theme/src/cli/services/package/types.ts index 051c9480462..32c572614f3 100644 --- a/packages/theme/src/cli/services/package/types.ts +++ b/packages/theme/src/cli/services/package/types.ts @@ -1,9 +1,12 @@ import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' import {zod} from '@shopify/cli-kit/node/schema' +import {isAbsolutePath} from '@shopify/cli-kit/node/path' export const themePackageJsonOutputSchema = defineJsonOutputSchema({ name: 'ThemePackageResult', - schema: zod.object({path: zod.string()}), + schema: zod + .object({path: zod.string().refine(isAbsolutePath).describe('The absolute native path of the ZIP archive.')}) + .strict(), }) export type ThemePackageResult = InferJsonOutputSchema From 65d8ccfa0964bc826fdd8653a66e140efed5f3cc Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:16:44 +0200 Subject: [PATCH 3/5] Refresh theme package JSON schema documentation --- packages/cli/README.md | 28 +++++++++++++++++++++++++++- packages/cli/oclif.manifest.json | 2 +- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 597b5d000a6..11219e4a5a2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -13055,10 +13055,14 @@ Package your theme into a .zip file, ready to upload to the Online Store. ``` USAGE - $ shopify theme package [--auth-alias ] [--json-schema] [--no-color] [--no-input] [--path ] + $ shopify theme package [--auth-alias ] [-j] [--json-schema] [--no-color] [--no-input] [--path ] [--verbose] FLAGS + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + --auth-alias= Alias of the Shopify account to use for authentication. [env: SHOPIFY_FLAG_AUTH_ALIAS] @@ -13097,6 +13101,28 @@ DESCRIPTION The ZIP file uses the name `theme_name-theme_version.zip`, based on parameters in your "settings_schema.json" (https://shopify.dev/docs/storefronts/themes/architecture/config/settings-schema-json) file. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ThemePackageResult` schema. + + ```json + { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The absolute native path of the ZIP archive." + } + }, + "required": [ + "path" + ], + "additionalProperties": false, + "title": "ThemePackageResult", + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify theme preview` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index d4a2ba3fc78..3c357b15e3d 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11849,7 +11849,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Packages your local theme files into a ZIP file that can be uploaded to Shopify.\n\n Only folders that match the \"default Shopify theme folder structure\" (https://shopify.dev/docs/storefronts/themes/tools/cli#directory-structure) are included in the package.\n\n The package includes the `listings` directory if present (required for multi-preset themes per \"Theme Store requirements\" (https://shopify.dev/docs/storefronts/themes/store/requirements#adding-presets-to-your-theme-zip-submission)).\n\n The ZIP file uses the name `theme_name-theme_version.zip`, based on parameters in your \"settings_schema.json\" (https://shopify.dev/docs/storefronts/themes/architecture/config/settings-schema-json) file.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePackageResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"path\"\n ],\n \"additionalProperties\": false,\n \"title\": \"ThemePackageResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Packages your local theme files into a ZIP file that can be uploaded to Shopify.\n\n Only folders that match the \"default Shopify theme folder structure\" (https://shopify.dev/docs/storefronts/themes/tools/cli#directory-structure) are included in the package.\n\n The package includes the `listings` directory if present (required for multi-preset themes per \"Theme Store requirements\" (https://shopify.dev/docs/storefronts/themes/store/requirements#adding-presets-to-your-theme-zip-submission)).\n\n The ZIP file uses the name `theme_name-theme_version.zip`, based on parameters in your \"settings_schema.json\" (https://shopify.dev/docs/storefronts/themes/architecture/config/settings-schema-json) file.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePackageResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\",\n \"description\": \"The absolute native path of the ZIP archive.\"\n }\n },\n \"required\": [\n \"path\"\n ],\n \"additionalProperties\": false,\n \"title\": \"ThemePackageResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Packages your local theme files into a ZIP file that can be uploaded to Shopify.\n\n Only folders that match the [default Shopify theme folder structure](https://shopify.dev/docs/storefronts/themes/tools/cli#directory-structure) are included in the package.\n\n The package includes the `listings` directory if present (required for multi-preset themes per [Theme Store requirements](https://shopify.dev/docs/storefronts/themes/store/requirements#adding-presets-to-your-theme-zip-submission)).\n\n The ZIP file uses the name `theme_name-theme_version.zip`, based on parameters in your [settings_schema.json](https://shopify.dev/docs/storefronts/themes/architecture/config/settings-schema-json) file.", "enableJsonFlag": false, "flags": { From fecc9df2c3a610abec8c966c7d1674e3d9ec2036 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:17:15 +0200 Subject: [PATCH 4/5] Refresh generated theme package 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 ce376fb402a..cddd9badd65 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -9618,9 +9618,18 @@ "description": "Increase the verbosity of the output. May include sensitive data.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-package.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" } ], - "value": "export interface themepackage {\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 * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\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 themepackage {\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 * 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 * 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}" } }, "themepreview": { From 5c64f2511064c9266c8d271053b15b4ad267a135 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 15:55:30 +0200 Subject: [PATCH 5/5] Reject paths in theme package filenames --- .../src/cli/commands/theme/package.test.ts | 5 +++- .../theme/src/cli/services/package.test.ts | 27 +++++++++++++++++++ packages/theme/src/cli/services/package.ts | 4 +++ .../theme/src/cli/services/package/result.ts | 5 +++- 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/theme/src/cli/commands/theme/package.test.ts b/packages/theme/src/cli/commands/theme/package.test.ts index a6b41fa7836..734d4f6b213 100644 --- a/packages/theme/src/cli/commands/theme/package.test.ts +++ b/packages/theme/src/cli/commands/theme/package.test.ts @@ -8,6 +8,9 @@ import {joinPath, relativizePath, cwd} from '@shopify/cli-kit/node/path' import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {renderSuccess} 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/ui') vi.mock('@shopify/cli-kit/node/analytics') @@ -31,7 +34,7 @@ test.each([true, false])('writes the package receipt to stdout with version=%s', await withCapturedStandardStreams(async ({stdout, stderr}) => { await runWithCommandEventsForCommand(['--json'], () => run(['--path', directory, '--json'])) - expect(stdout()).toBe(`${themePackageJsonOutputSchema.encode({path})}\n`) + expect(stdout()).toBe(`${themePackageJsonOutputSchema.encode({path: nativePath(path)})}\n`) expect(stderr()).toBe('') }) await expect(fileExists(path)).resolves.toBe(true) diff --git a/packages/theme/src/cli/services/package.test.ts b/packages/theme/src/cli/services/package.test.ts index 3a2adaefa4e..0cddce7caf0 100644 --- a/packages/theme/src/cli/services/package.test.ts +++ b/packages/theme/src/cli/services/package.test.ts @@ -9,6 +9,33 @@ const StreamZip = require('node-stream-zip') vi.mock('@shopify/cli-kit/node/ui') describe('packageTheme', () => { + test.each([ + {name: '../outside', version: ''}, + {name: 'absolute', version: ''}, + {name: 'C:\\outside', version: ''}, + {name: 'C:outside', version: ''}, + {name: 'Dawn', version: '../outside'}, + {name: 'Dawn', version: '..\\outside'}, + ])('rejects paths in the package filename: %j', async ({name, version}) => { + await inTemporaryDirectory(async (tmpDir) => { + const inputDirectory = joinPath(tmpDir, 'theme') + await mkdir(joinPath(inputDirectory, 'config')) + await createSettingsSchema( + JSON.stringify([ + { + name: 'theme_info', + theme_name: name === 'absolute' ? joinPath(tmpDir, 'outside') : name, + theme_version: version, + }, + ]), + inputDirectory, + ) + + await expect(packageTheme(inputDirectory)).rejects.toThrow('Theme name and version must not contain paths.') + await expect(fileExists(joinPath(tmpDir, 'outside.zip'))).resolves.toBe(false) + }) + }) + test('creates zip file from theme', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given diff --git a/packages/theme/src/cli/services/package.ts b/packages/theme/src/cli/services/package.ts index b8c670aa300..dca85d5efd4 100644 --- a/packages/theme/src/cli/services/package.ts +++ b/packages/theme/src/cli/services/package.ts @@ -53,6 +53,10 @@ async function getThemePackageName(inputDirectory: string) { const themeNameVersion = [themeInfo.theme_name, themeInfo.theme_version].filter(Boolean).join('-') + if (/[/\\]/.test(themeNameVersion) || /^[a-z]:/i.test(themeNameVersion)) { + throw new AbortError('Theme name and version must not contain paths.') + } + return `${themeNameVersion}.zip` } diff --git a/packages/theme/src/cli/services/package/result.ts b/packages/theme/src/cli/services/package/result.ts index 5ed5cefcc0b..de42b9b4a40 100644 --- a/packages/theme/src/cli/services/package/result.ts +++ b/packages/theme/src/cli/services/package/result.ts @@ -2,10 +2,13 @@ import {themePackageJsonOutputSchema, type ThemePackageResult} from './types.js' import {outputResult} from '@shopify/cli-kit/node/output' import {relativizePath} from '@shopify/cli-kit/node/path' import {renderSuccess} from '@shopify/cli-kit/node/ui' +// 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' export function renderThemePackageResult(result: ThemePackageResult, format: 'text' | 'json'): void { if (format === 'json') { - outputResult(themePackageJsonOutputSchema.encode(result)) + outputResult(themePackageJsonOutputSchema.encode({...result, path: resolvePath(result.path)})) return }