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
106 changes: 106 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9329,6 +9329,112 @@ DESCRIPTION
}
}
```


Use `--json-schema` to print the result, error, and event schemas.

Output from `--json` conforms to the `ThemePushJsonResult` schema.

```json
{
"anyOf": [
{
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"success",
"failed"
]
},
"environment": {
"type": "string"
},
"theme": {
"$ref": "#/definitions/ThemePushTheme"
}
},
"required": [
"status",
"theme"
],
"additionalProperties": false
},
{
"type": "array",
"items": {
"type": "object",
"properties": {
"status": {
"$ref": "#/definitions/ThemePushJsonResult/anyOf/0/properties/status"
},
"environment": {
"type": "string"
},
"theme": {
"$ref": "#/definitions/ThemePushTheme"
}
},
"required": [
"status",
"environment",
"theme"
],
"additionalProperties": false
}
}
],
"title": "ThemePushJsonResult",
"definitions": {
"ThemePushTheme": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"name": {
"type": "string"
},
"role": {
"type": "string"
},
"shop": {
"type": "string"
},
"editor_url": {
"type": "string"
},
"preview_url": {
"type": "string"
},
"warning": {
"type": "string"
},
"errors": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"required": [
"id",
"name",
"role",
"shop",
"editor_url",
"preview_url"
],
"additionalProperties": false
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}
```
```

## `shopify theme rename`
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -11336,7 +11336,7 @@
"args": {
},
"customPluginName": "@shopify/theme",
"description": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You can run this command only in a directory that matches the \"default Shopify theme folder structure\" (https://shopify.dev/docs/themes/tools/cli#directory-structure).\n\n This command returns the following information:\n\n - A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A \"preview link\" (https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\",\n \"editor_url\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"preview_url\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n ",
"description": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You can run this command only in a directory that matches the \"default Shopify theme folder structure\" (https://shopify.dev/docs/themes/tools/cli#directory-structure).\n\n This command returns the following information:\n\n - A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A \"preview link\" (https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\",\n \"editor_url\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"preview_url\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePushJsonResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"success\",\n \"failed\"\n ]\n },\n \"environment\": {\n \"type\": \"string\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePushTheme\"\n }\n },\n \"required\": [\n \"status\",\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"$ref\": \"#/definitions/ThemePushJsonResult/anyOf/0/properties/status\"\n },\n \"environment\": {\n \"type\": \"string\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePushTheme\"\n }\n },\n \"required\": [\n \"status\",\n \"environment\",\n \"theme\"\n ],\n \"additionalProperties\": false\n }\n }\n ],\n \"title\": \"ThemePushJsonResult\",\n \"definitions\": {\n \"ThemePushTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"number\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\"\n },\n \"shop\": {\n \"type\": \"string\"\n },\n \"editor_url\": {\n \"type\": \"string\"\n },\n \"preview_url\": {\n \"type\": \"string\"\n },\n \"warning\": {\n \"type\": \"string\"\n },\n \"errors\": {\n \"type\": \"object\",\n \"additionalProperties\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"shop\",\n \"editor_url\",\n \"preview_url\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```",
"descriptionWithMarkdown": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You can run this command only in a directory that matches the [default Shopify theme folder structure](https://shopify.dev/docs/themes/tools/cli#directory-structure).\n\n This command returns the following information:\n\n - A link to the [editor](https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A [preview link](https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\",\n \"editor_url\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"preview_url\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n ",
"enableJsonFlag": false,
"flags": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ const commandExceptions = [
'packages/theme/src/cli/commands/theme/preview.ts',
'packages/theme/src/cli/commands/theme/profile.ts',
'packages/theme/src/cli/commands/theme/pull.ts',
'packages/theme/src/cli/commands/theme/push.ts',
'packages/theme/src/cli/commands/theme/share.ts',

// Streaming commands without a single finite result.
Expand Down
80 changes: 80 additions & 0 deletions packages/theme/src/cli/commands/theme/push.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import Push from './push.js'
import {executeThemePush} from '../../services/push.js'
import {checkThemeBeforePush} from '../../services/push/result.js'
import {themePushJsonOutputSchema} from '../../services/push/types.js'
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
import {describe, expect, test, vi} from 'vitest'
import {Config} from '@oclif/core'
import {AbortError} from '@shopify/cli-kit/node/error'

vi.mock('../../services/push.js')
vi.mock('../../services/push/result.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../../services/push/result.js')>()),
checkThemeBeforePush: vi.fn(),
}))

const session = {storeFqdn: 'test.myshopify.com', token: 'token'}

describe('theme push JSON', () => {
test('exposes the schema in help and retains JSON and inherited flags', () => {
expect(Push.jsonOutputSchema).toBe(themePushJsonOutputSchema)
expect(Push.description).toContain('ThemePushJsonResult')
expect(Push.flags.json).toBeDefined()
expect(Push.baseFlags).toHaveProperty('json-schema')
})

test('executes and writes the result through the real encoder', async () => {
vi.mocked(executeThemePush).mockResolvedValue({
theme: {
id: 1,
name: 'Theme',
role: 'unpublished',
shop: session.storeFqdn,
editor_url: 'editor',
preview_url: 'preview',
},
published: false,
hasErrors: false,
errors: {},
})
const command = new Push([], new Config({root: '.'}))
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await command.command({json: true} as never, session, false)

expect(JSON.parse(stdout())).toEqual({
status: 'success',
theme: {
id: 1,
name: 'Theme',
role: 'unpublished',
shop: session.storeFqdn,
editor_url: 'editor',
preview_url: 'preview',
},
})
expect(stderr()).toBe('')
})
})

test('retains cancellation without emitting a success result', async () => {
vi.mocked(executeThemePush).mockResolvedValue(undefined)
const command = new Push([], new Config({root: '.'}))
await withCapturedStandardStreams(async ({stdout}) => {
await command.command({json: true} as never, session, false)

expect(stdout()).toBe('')
})
})

test('propagates strict failures without pushing or writing a result', async () => {
const failure = new AbortError('Theme check failed. Please fix the errors before pushing.')
vi.mocked(checkThemeBeforePush).mockRejectedValueOnce(failure)
const command = new Push([], new Config({root: '.'}))
await withCapturedStandardStreams(async ({stdout}) => {
await expect(command.command({json: true, strict: true} as never, session, false)).rejects.toBe(failure)

expect(executeThemePush).not.toHaveBeenCalled()
expect(stdout()).toBe('')
})
})
})
25 changes: 23 additions & 2 deletions packages/theme/src/cli/commands/theme/push.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import {globFlags, themeFlags} from '../../flags.js'
import ThemeCommand from '../../utilities/theme-command.js'
import {push} from '../../services/push.js'
import {executeThemePush} from '../../services/push.js'
import {themePushJsonOutputSchema} from '../../services/push/types.js'
import {
checkThemeBeforePush,
renderThemePushResult,
renderThemePushEnvironmentResults,
} from '../../services/push/result.js'
import {Flags} from '@oclif/core'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {recordTiming} from '@shopify/cli-kit/node/analytics'
Expand All @@ -13,6 +19,10 @@ import type {NonTTYFlagRequirement} from '@shopify/cli-kit/node/base-command'
type PushFlags = InferredFlags<typeof Push.flags>

export default class Push extends ThemeCommand {
static get jsonOutputSchema() {
return themePushJsonOutputSchema
}

static summary = 'Uploads your local theme files to the connected store, overwriting the remote version if specified.'

static usage = ['theme push', 'theme push --unpublished --json']
Expand Down Expand Up @@ -137,7 +147,8 @@ export default class Push extends ThemeCommand {
context?: {stdout?: Writable; stderr?: Writable},
) {
recordTiming('theme-command:push')
await push(
await checkThemeBeforePush(flags)
const result = await executeThemePush(
{
...flags,
allowLive: flags['allow-live'],
Expand All @@ -148,7 +159,17 @@ export default class Push extends ThemeCommand {
multiEnvironment,
context,
)
if (result && !(flags.json && multiEnvironment)) renderThemePushResult(result, flags.json ? 'json' : 'text')
recordTiming('theme-command:push')
return result
}

protected collectsEnvironmentResults(flags: {json?: boolean}): boolean {
return Boolean(flags.json)
}

protected renderEnvironmentResults(results: {environment: string; result: unknown}[]): void {
renderThemePushEnvironmentResults(results)
}

protected storeAuthScopes(): string[] {
Expand Down
109 changes: 109 additions & 0 deletions packages/theme/src/cli/commands/theme/transfer-environments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import Push from './push.js'
import {executeThemePush} from '../../services/push.js'
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
import {loadEnvironment} from '@shopify/cli-kit/node/environments'
import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session'
import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs'
import {runWithCommandEvents, renderCommandEventAsJson} from '@shopify/cli-kit/node/command-events'
import {Config} from '@oclif/core'
import {expect, test, vi} from 'vitest'

vi.mock('../../services/push.js')
vi.mock('@shopify/cli-kit/node/environments')
vi.mock('@shopify/cli-kit/node/session')
vi.mock('@shopify/cli-kit/node/metadata')

class TestPush extends Push {
public parse = vi.fn()
}

// Exercise real environment orchestration, presenter, encoder and streams.
test.each(['none', 'partial', 'total'] as const)(
'collects environment successes in requested order with %s failures',
async (failures) => {
await inTemporaryDirectory(async (path) => {
vi.mocked(loadEnvironment).mockImplementation(async (name) => ({
store: name === 'second' ? 'second.myshopify.com' : 'first.myshopify.com',
password: 'password',
path,
theme: '1',
}))
vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store) => ({storeFqdn: store, token: 'token'}))
let releaseFirst: () => void = () => {}
const secondStarted = new Promise<void>((resolve) => {
releaseFirst = resolve
})
const executionOrder: string[] = []
vi.mocked(executeThemePush).mockImplementation(async (flags, session) => {
const environment = flags.environment![0]!
if (environment === 'first') await secondStarted
if (environment === 'second') releaseFirst()
executionOrder.push(environment)
if (failures === 'total' || (failures === 'partial' && environment === 'second'))
throw new Error('upload failed')
return {
environment,
theme: {
id: 1,
name: environment,
role: 'unpublished',
shop: session!.storeFqdn,
editor_url: 'editor',
preview_url: 'preview',
},
published: false,
hasErrors: false,
errors: {},
}
})
const command = new TestPush([], new Config({root: path}))
const flags = {json: true, force: true, environment: ['first', 'second', 'third']}
vi.spyOn(command, 'parse').mockResolvedValue({flags, args: {}} as never)
const exitCode = process.exitCode
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run())

const results = JSON.parse(stdout())
expect(results.map((result: {environment: string}) => result.environment)).toEqual(
failures === 'total' ? [] : ['first', ...(failures === 'none' ? ['second'] : []), 'third'],
)
expect(executionOrder).toEqual(['second', 'first', 'third'])
expect(process.exitCode).toBe(exitCode)
const events = stderr().trim()
? stderr()
.trim()
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line))
: []
expect(events.filter((event) => event.level === 'error')).toHaveLength(
{none: 0, partial: 1, total: 3}[failures],
)
})
})
},
)

test('returns an empty array when every environment is invalid', async () => {
vi.mocked(loadEnvironment).mockResolvedValue({})
const command = new TestPush([], new Config({root: '.'}))
vi.spyOn(command, 'parse').mockResolvedValue({
flags: {json: true, force: true, environment: ['first', 'second']},
args: {},
} as never)
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run())

expect(JSON.parse(stdout())).toEqual([])
expect(executeThemePush).not.toHaveBeenCalled()
expect(
stderr()
.trim()
.split('\n')
.map((line) => JSON.parse(line)),
).toEqual([
expect.objectContaining({type: 'diagnostic', level: 'warning'}),
expect.objectContaining({type: 'diagnostic', level: 'warning'}),
])
})
})
1 change: 1 addition & 0 deletions packages/theme/src/cli/services/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ describe('push', () => {
// Then
expect(outputResult).toHaveBeenCalledWith(
JSON.stringify({
status: 'failed',
theme: {
id: 1,
name: 'Theme',
Expand Down
Loading
Loading