Skip to content

Commit 8b08bde

Browse files
Add typed JSON output to theme push
1 parent e2e7e23 commit 8b08bde

15 files changed

Lines changed: 809 additions & 199 deletions

File tree

‎packages/cli/README.md‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8846,6 +8846,100 @@ DESCRIPTION
88468846
}
88478847
}
88488848
```
8849+
8850+
8851+
Output from `--json` conforms to the `ThemePushJsonResult` schema.
8852+
8853+
Use `--json-schema` to print the result, error, and event schemas.
8854+
8855+
```json
8856+
{
8857+
"anyOf": [
8858+
{
8859+
"type": "object",
8860+
"properties": {
8861+
"environment": {
8862+
"type": "string"
8863+
},
8864+
"theme": {
8865+
"$ref": "#/definitions/ThemePushTheme"
8866+
}
8867+
},
8868+
"required": [
8869+
"theme"
8870+
],
8871+
"additionalProperties": false
8872+
},
8873+
{
8874+
"type": "array",
8875+
"items": {
8876+
"type": "object",
8877+
"properties": {
8878+
"environment": {
8879+
"type": "string"
8880+
},
8881+
"theme": {
8882+
"$ref": "#/definitions/ThemePushTheme"
8883+
}
8884+
},
8885+
"required": [
8886+
"environment",
8887+
"theme"
8888+
],
8889+
"additionalProperties": false
8890+
}
8891+
}
8892+
],
8893+
"title": "ThemePushJsonResult",
8894+
"definitions": {
8895+
"ThemePushTheme": {
8896+
"type": "object",
8897+
"properties": {
8898+
"id": {
8899+
"type": "number"
8900+
},
8901+
"name": {
8902+
"type": "string"
8903+
},
8904+
"role": {
8905+
"type": "string"
8906+
},
8907+
"shop": {
8908+
"type": "string"
8909+
},
8910+
"editor_url": {
8911+
"type": "string"
8912+
},
8913+
"preview_url": {
8914+
"type": "string"
8915+
},
8916+
"warning": {
8917+
"type": "string"
8918+
},
8919+
"errors": {
8920+
"type": "object",
8921+
"additionalProperties": {
8922+
"type": "array",
8923+
"items": {
8924+
"type": "string"
8925+
}
8926+
}
8927+
}
8928+
},
8929+
"required": [
8930+
"id",
8931+
"name",
8932+
"role",
8933+
"shop",
8934+
"editor_url",
8935+
"preview_url"
8936+
],
8937+
"additionalProperties": false
8938+
}
8939+
},
8940+
"$schema": "http://json-schema.org/draft-07/schema#"
8941+
}
8942+
```
88498943
```
88508944
88518945
## `shopify theme rename`

‎packages/cli/oclif.manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11327,7 +11327,7 @@
1132711327
"args": {
1132811328
},
1132911329
"customPluginName": "@shopify/theme",
11330-
"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 ",
11330+
"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\nOutput from `--json` conforms to the `ThemePushJsonResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePushTheme\"\n }\n },\n \"required\": [\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePushTheme\"\n }\n },\n \"required\": [\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```",
1133111331
"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 ",
1133211332
"enableJsonFlag": false,
1133311333
"flags": {

‎packages/eslint-plugin-cli/rules/json-output-command-exceptions.js‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ const commandExceptions = [
6767
'packages/theme/src/cli/commands/theme/preview.ts',
6868
'packages/theme/src/cli/commands/theme/profile.ts',
6969
'packages/theme/src/cli/commands/theme/pull.ts',
70-
'packages/theme/src/cli/commands/theme/push.ts',
7170
'packages/theme/src/cli/commands/theme/share.ts',
7271

7372
// Streaming commands without a single finite result.
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import Push from './push.js'
2+
import {executeThemePush} from '../../services/push.js'
3+
import {checkThemeBeforePush} from '../../services/push/result.js'
4+
import {themePushJsonOutputSchema} from '../../services/push/types.js'
5+
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
6+
import {describe, expect, test, vi} from 'vitest'
7+
import {Config} from '@oclif/core'
8+
import {AbortError} from '@shopify/cli-kit/node/error'
9+
10+
vi.mock('../../services/push.js')
11+
vi.mock('../../services/push/result.js', async (importOriginal) => ({
12+
...(await importOriginal<typeof import('../../services/push/result.js')>()),
13+
checkThemeBeforePush: vi.fn(),
14+
}))
15+
16+
const session = {storeFqdn: 'test.myshopify.com', token: 'token'}
17+
18+
describe('theme push JSON', () => {
19+
test('exposes the schema in help and retains JSON and inherited flags', () => {
20+
expect(Push.jsonOutputSchema).toBe(themePushJsonOutputSchema)
21+
expect(Push.description).toContain('ThemePushJsonResult')
22+
expect(Push.flags.json).toBeDefined()
23+
expect(Push.baseFlags).toHaveProperty('json-schema')
24+
})
25+
26+
test('executes and writes the result through the real encoder', async () => {
27+
vi.mocked(executeThemePush).mockResolvedValue({
28+
theme: {
29+
id: 1,
30+
name: 'Theme',
31+
role: 'unpublished',
32+
shop: session.storeFqdn,
33+
editor_url: 'editor',
34+
preview_url: 'preview',
35+
},
36+
published: false,
37+
hasErrors: false,
38+
errors: {},
39+
})
40+
const command = new Push([], new Config({root: '.'}))
41+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
42+
await command.command({json: true} as never, session, false)
43+
44+
expect(JSON.parse(stdout())).toEqual({
45+
theme: {
46+
id: 1,
47+
name: 'Theme',
48+
role: 'unpublished',
49+
shop: session.storeFqdn,
50+
editor_url: 'editor',
51+
preview_url: 'preview',
52+
},
53+
})
54+
expect(stderr()).toBe('')
55+
})
56+
})
57+
58+
test('retains cancellation without emitting a success result', async () => {
59+
vi.mocked(executeThemePush).mockResolvedValue(undefined)
60+
const command = new Push([], new Config({root: '.'}))
61+
await withCapturedStandardStreams(async ({stdout}) => {
62+
await command.command({json: true} as never, session, false)
63+
64+
expect(stdout()).toBe('')
65+
})
66+
})
67+
68+
test('propagates strict failures without pushing or writing a result', async () => {
69+
const failure = new AbortError('Theme check failed. Please fix the errors before pushing.')
70+
vi.mocked(checkThemeBeforePush).mockRejectedValueOnce(failure)
71+
const command = new Push([], new Config({root: '.'}))
72+
await withCapturedStandardStreams(async ({stdout}) => {
73+
await expect(command.command({json: true, strict: true} as never, session, false)).rejects.toBe(failure)
74+
75+
expect(executeThemePush).not.toHaveBeenCalled()
76+
expect(stdout()).toBe('')
77+
})
78+
})
79+
})

‎packages/theme/src/cli/commands/theme/push.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import {globFlags, themeFlags} from '../../flags.js'
22
import ThemeCommand from '../../utilities/theme-command.js'
3-
import {push} from '../../services/push.js'
3+
import {executeThemePush} from '../../services/push.js'
4+
import {themePushJsonOutputSchema} from '../../services/push/types.js'
5+
import {
6+
checkThemeBeforePush,
7+
renderThemePushResult,
8+
renderThemePushEnvironmentResults,
9+
} from '../../services/push/result.js'
410
import {Flags} from '@oclif/core'
511
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
612
import {recordTiming} from '@shopify/cli-kit/node/analytics'
@@ -13,6 +19,10 @@ import type {NonTTYFlagRequirement} from '@shopify/cli-kit/node/base-command'
1319
type PushFlags = InferredFlags<typeof Push.flags>
1420

1521
export default class Push extends ThemeCommand {
22+
static get jsonOutputSchema() {
23+
return themePushJsonOutputSchema
24+
}
25+
1626
static summary = 'Uploads your local theme files to the connected store, overwriting the remote version if specified.'
1727

1828
static usage = ['theme push', 'theme push --unpublished --json']
@@ -137,7 +147,8 @@ export default class Push extends ThemeCommand {
137147
context?: {stdout?: Writable; stderr?: Writable},
138148
) {
139149
recordTiming('theme-command:push')
140-
await push(
150+
await checkThemeBeforePush(flags)
151+
const result = await executeThemePush(
141152
{
142153
...flags,
143154
allowLive: flags['allow-live'],
@@ -148,7 +159,17 @@ export default class Push extends ThemeCommand {
148159
multiEnvironment,
149160
context,
150161
)
162+
if (result && !(flags.json && multiEnvironment)) renderThemePushResult(result, flags.json ? 'json' : 'text')
151163
recordTiming('theme-command:push')
164+
return result
165+
}
166+
167+
protected collectsEnvironmentResults(flags: {json?: boolean}): boolean {
168+
return Boolean(flags.json)
169+
}
170+
171+
protected renderEnvironmentResults(results: {environment: string; result: unknown}[]): void {
172+
renderThemePushEnvironmentResults(results)
152173
}
153174

154175
protected storeAuthScopes(): string[] {
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import Push from './push.js'
2+
import {executeThemePush} from '../../services/push.js'
3+
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
4+
import {loadEnvironment} from '@shopify/cli-kit/node/environments'
5+
import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session'
6+
import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs'
7+
import {runWithCommandEvents, renderCommandEventAsJson} from '@shopify/cli-kit/node/command-events'
8+
import {Config} from '@oclif/core'
9+
import {expect, test, vi} from 'vitest'
10+
11+
vi.mock('../../services/push.js')
12+
vi.mock('@shopify/cli-kit/node/environments')
13+
vi.mock('@shopify/cli-kit/node/session')
14+
vi.mock('@shopify/cli-kit/node/metadata')
15+
16+
class TestPush extends Push {
17+
public parse = vi.fn()
18+
}
19+
20+
// Exercise real environment orchestration, presenter, encoder and streams.
21+
test.each(['none', 'partial', 'total'] as const)(
22+
'collects environment successes in requested order with %s failures',
23+
async (failures) => {
24+
await inTemporaryDirectory(async (path) => {
25+
vi.mocked(loadEnvironment).mockImplementation(async (name) => ({
26+
store: name === 'second' ? 'second.myshopify.com' : 'first.myshopify.com',
27+
password: 'password',
28+
path,
29+
theme: '1',
30+
}))
31+
vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store) => ({storeFqdn: store, token: 'token'}))
32+
let releaseFirst: () => void = () => {}
33+
const secondStarted = new Promise<void>((resolve) => {
34+
releaseFirst = resolve
35+
})
36+
const executionOrder: string[] = []
37+
vi.mocked(executeThemePush).mockImplementation(async (flags, session) => {
38+
const environment = flags.environment![0]!
39+
if (environment === 'first') await secondStarted
40+
if (environment === 'second') releaseFirst()
41+
executionOrder.push(environment)
42+
if (failures === 'total' || (failures === 'partial' && environment === 'second'))
43+
throw new Error('upload failed')
44+
return {
45+
environment,
46+
theme: {
47+
id: 1,
48+
name: environment,
49+
role: 'unpublished',
50+
shop: session!.storeFqdn,
51+
editor_url: 'editor',
52+
preview_url: 'preview',
53+
},
54+
published: false,
55+
hasErrors: false,
56+
errors: {},
57+
}
58+
})
59+
const command = new TestPush([], new Config({root: path}))
60+
const flags = {json: true, force: true, environment: ['first', 'second', 'third']}
61+
vi.spyOn(command, 'parse').mockResolvedValue({flags, args: {}} as never)
62+
const exitCode = process.exitCode
63+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
64+
await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run())
65+
66+
const results = JSON.parse(stdout())
67+
expect(results.map((result: {environment: string}) => result.environment)).toEqual(
68+
failures === 'total' ? [] : ['first', ...(failures === 'none' ? ['second'] : []), 'third'],
69+
)
70+
expect(executionOrder).toEqual(['second', 'first', 'third'])
71+
expect(process.exitCode).toBe(exitCode)
72+
const events = stderr().trim()
73+
? stderr()
74+
.trim()
75+
.split('\n')
76+
.filter(Boolean)
77+
.map((line) => JSON.parse(line))
78+
: []
79+
expect(events.filter((event) => event.level === 'error')).toHaveLength(
80+
{none: 0, partial: 1, total: 3}[failures],
81+
)
82+
})
83+
})
84+
},
85+
)
86+
87+
test('returns an empty array when every environment is invalid', async () => {
88+
vi.mocked(loadEnvironment).mockResolvedValue({})
89+
const command = new TestPush([], new Config({root: '.'}))
90+
vi.spyOn(command, 'parse').mockResolvedValue({
91+
flags: {json: true, force: true, environment: ['first', 'second']},
92+
args: {},
93+
} as never)
94+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
95+
await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run())
96+
97+
expect(JSON.parse(stdout())).toEqual([])
98+
expect(executeThemePush).not.toHaveBeenCalled()
99+
expect(
100+
stderr()
101+
.trim()
102+
.split('\n')
103+
.map((line) => JSON.parse(line)),
104+
).toEqual([
105+
expect.objectContaining({type: 'diagnostic', level: 'warning'}),
106+
expect.objectContaining({type: 'diagnostic', level: 'warning'}),
107+
])
108+
})
109+
})

0 commit comments

Comments
 (0)