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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <value>'?: 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 <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
"value": "export interface themepackage {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * 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 <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
}
},
"themepreview": {
Expand Down
28 changes: 27 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13055,10 +13055,14 @@ Package your theme into a .zip file, ready to upload to the Online Store.

```
USAGE
$ shopify theme package [--auth-alias <value>] [--json-schema] [--no-color] [--no-input] [--path <value>]
$ shopify theme package [--auth-alias <value>] [-j] [--json-schema] [--no-color] [--no-input] [--path <value>]
[--verbose]

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

--auth-alias=<value>
Alias of the Shopify account to use for authentication.
[env: SHOPIFY_FLAG_AUTH_ALIAS]
Expand Down Expand Up @@ -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`
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 \"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": {
Expand All @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
71 changes: 71 additions & 0 deletions packages/theme/src/cli/commands/theme/package.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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'
// 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')
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: nativePath(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()
expect(() => themePackageJsonOutputSchema.validate({path: 'Dawn.zip'})).toThrow()
expect(() => themePackageJsonOutputSchema.validate({path: joinPath(cwd(), 'Dawn.zip'), extra: true})).toThrow()
})
12 changes: 10 additions & 2 deletions packages/theme/src/cli/commands/theme/package.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Package.flags>
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.
Expand All @@ -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')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we make the archive writer reject output errors before emitting this receipt? With a valid settings_schema.json and an existing Dawn.zip directory, --json returns {path: ".../Dawn.zip"} and exits 0, but no ZIP was written. I checked this with the real writer and files. zip() currently resolves when the output stream closes even when opening it fails. This needs to use the fatal JSON error path.


🤖 Generated by the review-pr skill

}
}
48 changes: 36 additions & 12 deletions packages/theme/src/cli/services/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,7 +60,7 @@ describe('packageTheme', () => {
)

// When
await packageTheme(inputDirectory)
const result = await packageTheme(inputDirectory)

// Then
const expectedOutputZipPath = joinPath(inputDirectory, 'Dawn-7.0.2.zip')
Expand All @@ -42,9 +69,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()
})
})

Expand Down Expand Up @@ -73,7 +99,7 @@ describe('packageTheme', () => {
)

// When
await packageTheme(inputDirectory)
const result = await packageTheme(inputDirectory)

// Then
const expectedOutputZipPath = joinPath(inputDirectory, 'Dawn-7.0.2.zip')
Expand All @@ -82,9 +108,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()
})
})

Expand All @@ -105,7 +130,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')
Expand All @@ -114,9 +139,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()
})
})

Expand Down
16 changes: 9 additions & 7 deletions packages/theme/src/cli/services/package.ts
Original file line number Diff line number Diff line change
@@ -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 = [
Expand All @@ -23,10 +23,10 @@ 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<ThemePackageResult> {
const packageName = await getThemePackageName(inputDirectory)

const outputZipPath = `${inputDirectory}/${packageName}`
const outputZipPath = resolvePath(inputDirectory, packageName)
const matchFilePattern = `${inputDirectory}/(${themeFilesPattern})`

await zip({
Expand All @@ -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) {
Expand All @@ -55,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`
}

Expand Down
16 changes: 16 additions & 0 deletions packages/theme/src/cli/services/package/result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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, path: resolvePath(result.path)}))
return
}

renderSuccess({body: ['Your local theme was packaged in', {filePath: relativizePath(result.path)}]})
}
12 changes: 12 additions & 0 deletions packages/theme/src/cli/services/package/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +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().refine(isAbsolutePath).describe('The absolute native path of the ZIP archive.')})
.strict(),
})

export type ThemePackageResult = InferJsonOutputSchema<typeof themePackageJsonOutputSchema>
Loading