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
5 changes: 5 additions & 0 deletions .changeset/app-import-custom-data-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli': minor
---

Add typed JSON output to `app import custom-data-definitions`.
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 @@ -2695,6 +2695,15 @@
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_APP_CONFIG"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/app-import-custom-data-definitions.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/app-import-custom-data-definitions.interface.ts",
"syntaxKind": "PropertySignature",
Expand All @@ -2705,7 +2714,7 @@
"environmentValue": "SHOPIFY_FLAG_STORE"
}
],
"value": "export interface appimportcustomdatadefinitions {\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 * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * Include existing declared definitions in the output.\n * @environment SHOPIFY_FLAG_INCLUDE_EXISTING\n */\n '--include-existing'?: ''\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 to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Store URL. Must be an existing development or Shopify Plus sandbox store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store <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 appimportcustomdatadefinitions {\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 * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * Include existing declared definitions in the output.\n * @environment SHOPIFY_FLAG_INCLUDE_EXISTING\n */\n '--include-existing'?: ''\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 to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Store URL. Must be an existing development or Shopify Plus sandbox store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store <value>'?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
}
},
"appimportdashboardextensions": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import ImportCustomDataDefinitions from './custom-data-definitions.js'
import {importCustomDataDefinitionsJsonOutputSchema} from '../../../services/generate/shop-import/declarative-definitions/types.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {storeContext} from '../../../services/store-context.js'
import {adminAsAppRequestDoc} from '../../../api/admin-as-app.js'
import {MetafieldDefinitions} from '../../../api/graphql/admin/generated/metafield_definitions.js'
import {testAppLinked, testOrganizationApp, testOrganizationStore} from '../../../models/app/app.test-data.js'
import {Config} from '@oclif/core'
import {afterEach, expect, test, vi} from 'vitest'
import {inTemporaryDirectory, readFile, writeFile} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'
import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session'
import {unstyled} from '@shopify/cli-kit/node/output'
import {AbortError, handler} from '@shopify/cli-kit/node/error'
// eslint-disable-next-line n/prefer-global/console
import {Console} from 'node:console'

vi.mock('../../../services/app-context.js')
vi.mock('../../../services/store-context.js')
vi.mock('../../../api/admin-as-app.js')
vi.mock('@shopify/cli-kit/node/session')

afterEach(() => {
vi.unstubAllEnvs()
vi.unstubAllGlobals()
})

const toml =
'# namespace: $app key: color owner_type: PRODUCT\n[product.metafields.app.color]\ntype = "single_line_text_field"\n'

test.each([{metafieldCount: -1}, {storeDomain: 'custom.example.com'}, {skippedSections: [{type: 'unknown'}]}])(
'rejects invalid public fields: %j',
(fields) => {
expect(() =>
importCustomDataDefinitionsJsonOutputSchema.validate({
status: 'success',
storeDomain: 'test-shop.myshopify.com',
metafieldCount: 0,
metaobjectCount: 0,
toml: '',
skippedSections: [],
...fields,
}),
).toThrow()
},
)

async function withApp(run: (directory: string) => Promise<void>) {
// Ink uses the Console constructor, which Vitest's console replacement does not provide.
vi.stubGlobal('console', {...globalThis.console, Console})
await inTemporaryDirectory(async (directory) => {
const configurationPath = joinPath(directory, 'shopify.app.toml')
const configuration = 'name = "Test app"\nclient_id = "test-client-id"\n'
await writeFile(configurationPath, configuration)
const app = testAppLinked({directory, configPath: configurationPath})
const remoteApp = testOrganizationApp()
vi.mocked(linkedAppContext).mockResolvedValue({app, remoteApp} as Awaited<ReturnType<typeof linkedAppContext>>)
vi.mocked(storeContext).mockResolvedValue(testOrganizationStore({shopDomain: 'test-shop.myshopify.com'}))
vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue({
storeFqdn: 'test-shop.myshopify.com',
token: 'test-token',
})
vi.mocked(adminAsAppRequestDoc).mockImplementation(async ({query, variables}) => {
if (query === MetafieldDefinitions) {
return {
metafieldDefinitions: {
pageInfo: {hasNextPage: false, endCursor: null},
nodes:
variables?.ownerType === 'PRODUCT'
? [
{
key: 'color',
name: 'color',
namespace: 'app--123456',
type: {name: 'single_line_text_field'},
access: {admin: 'MERCHANT_READ', storefront: 'NONE', customerAccount: 'NONE'},
capabilities: {adminFilterable: {enabled: false}},
validations: [],
},
]
: [],
},
}
}
return {metaobjectDefinitions: {pageInfo: {hasNextPage: false, endCursor: null}, nodes: []}}
})
await run(directory)
await expect(readFile(configurationPath)).resolves.toBe(configuration)
})
}

async function runCommand(directory: string, argv: string[]) {
const args = ['--path', directory, ...argv]
const command = new ImportCustomDataDefinitions(args, await Config.load())
return runWithCommandEventsForCommand(args, () => command.run())
}

test.each([
{storeFqdn: 'test-shop.myshopify.com', storeDomain: 'test-shop.myshopify.com'},
{storeFqdn: 'test-shop.myshopify.io', storeDomain: null},
])('writes one JSON document for $storeFqdn without changing the local TOML', async ({storeFqdn, storeDomain}) => {
await withApp(async (directory) => {
vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue({storeFqdn, token: 'test-token'})
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runCommand(directory, ['--json', '--store', 'test-shop'])
expect(JSON.parse(stdout())).toEqual({
status: 'success',
storeDomain,
metafieldCount: 1,
metaobjectCount: 0,
toml,
skippedSections: [],
})
const events = stderr()
.trim()
.split('\n')
.map((line) => JSON.parse(line))
expect(events).toEqual(expect.arrayContaining([expect.objectContaining({type: 'progress', status: 'completed'})]))
expect(events.every((event) => event.type === 'progress')).toBe(true)
expect(stderr()).not.toContain('Conversion to TOML complete')
})
})
})

test.each([false, true])('distinguishes an empty conversion from denied scope: %s', async (scopeDenied) => {
await withApp(async (directory) => {
vi.mocked(adminAsAppRequestDoc).mockImplementation(async ({query, variables}) => {
if (scopeDenied && query === MetafieldDefinitions && variables?.ownerType === 'PRODUCT') {
throw new Error('ACCESS_DENIED: Missing access scope')
}
return {
[query === MetafieldDefinitions ? 'metafieldDefinitions' : 'metaobjectDefinitions']: {
pageInfo: {hasNextPage: false, endCursor: null},
nodes: [],
},
}
})
await withCapturedStandardStreams(async ({stdout}) => {
await runCommand(directory, ['--json'])
expect(JSON.parse(stdout())).toMatchObject({
metafieldCount: 0,
metaobjectCount: 0,
toml: '',
skippedSections: scopeDenied ? [{type: 'metafields', ownerType: 'PRODUCT'}] : [],
})
})
})
})

test('keeps the existing conversion summary and native TOML on stderr in text mode', async () => {
await withApp(async (directory) => {
vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue({
storeFqdn: 'test-shop.myshopify.io',
token: 'test-token',
})
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runCommand(directory, [])
expect(stdout()).toBe('')
const text = unstyled(stderr())
expect(text).toContain('Conversion to TOML complete.')
expect(text).toContain('1 metafields and 0 metaobjects')
expect(text).toContain('test-shop.myshopify.io')
expect(text).toContain(toml)
})
})
})

test('preserves a transport failure without printing a success result', async () => {
await withApp(async (directory) => {
vi.mocked(adminAsAppRequestDoc).mockRejectedValue(new AbortError('Definition request failed'))
await withCapturedStandardStreams(async ({stdout, stderr}) => {
const error = await runCommand(directory, ['--json']).catch((failure: AbortError) => failure)
expect(error).toBeInstanceOf(AbortError)
expect(stdout()).toBe('')
vi.stubEnv('SHOPIFY_FLAG_JSON', '1')
await runWithCommandEventsForCommand(['--json'], () => handler(error as AbortError))
expect(JSON.parse(stdout())).toMatchObject({error: {type: 'abort', message: 'Definition request failed'}})
expect(
stderr()
.trim()
.split('\n')
.map((line) => JSON.parse(line)),
).toEqual(expect.arrayContaining([expect.objectContaining({type: 'progress', status: 'failed'})]))
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-l
import {linkedAppContext} from '../../../services/app-context.js'
import {storeContext} from '../../../services/store-context.js'
import {importDeclarativeDefinitions} from '../../../services/generate/shop-import/declarative-definitions.js'
import {importCustomDataDefinitionsJsonOutputSchema} from '../../../services/generate/shop-import/declarative-definitions/types.js'
import {renderImportDeclarativeDefinitionsResult} from '../../../services/generate/shop-import/declarative-definitions/result.js'
import {Flags} from '@oclif/core'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {renderSingleTask} from '@shopify/cli-kit/node/ui'
import {outputContent} from '@shopify/cli-kit/node/output'

Expand All @@ -15,11 +17,16 @@ export default class ImportCustomDataDefinitions extends AppLinkedCommand {

static descriptionWithMarkdown = `Import metafield and metaobject definitions from your development store. [Read more about declarative custom data definitions](https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).`

static get jsonOutputSchema() {
return importCustomDataDefinitionsJsonOutputSchema
}

static description = this.descriptionForHelp()

static flags = {
...globalFlags,
...appFlags,
...jsonFlag,
store: Flags.string({
char: 's',
description: 'Store URL. Must be an existing development or Shopify Plus sandbox store.',
Expand All @@ -34,7 +41,7 @@ export default class ImportCustomDataDefinitions extends AppLinkedCommand {
}

public async run(): Promise<AppLinkedCommandOutput> {
const {appContextResult, ...options} = await renderSingleTask({
const {appContextResult, json, ...options} = await renderSingleTask({
title: outputContent`Loading application`,
task: async () => {
const {flags} = await this.parse(ImportCustomDataDefinitions)
Expand All @@ -55,14 +62,16 @@ export default class ImportCustomDataDefinitions extends AppLinkedCommand {

return {
appContextResult,
json: flags.json,
appConfiguration: appContextResult.app.configuration,
remoteApp: appContextResult.remoteApp,
store,
includeExistingDeclaredDefinitions: flags['include-existing'],
}
},
})
await importDeclarativeDefinitions(options)
const result = await importDeclarativeDefinitions(options)
renderImportDeclarativeDefinitionsResult(result, json)

return {app: appContextResult.app}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import {
processDeclarativeDefinitionNodes,
MetafieldNodesInput,
renderTomlStringWithFormatting,
paginatedQuery,
importDeclarativeDefinitions,
} from './declarative-definitions.js'
import {
renderTomlStringWithFormatting,
renderImportDeclarativeDefinitionsResult,
} from './declarative-definitions/result.js'
import {
MetaobjectDefinitions,
MetaobjectDefinitionsQuery,
Expand Down Expand Up @@ -1099,7 +1102,7 @@ describe('paginatedQuery', () => {
})

describe('importDeclarativeDefinitions', () => {
test('imports metafields and metaobjects from a shop and outputs TOML', async () => {
test('returns a known empty conversion without final terminal output', async () => {
const outputMock = mockAndCaptureOutput()

vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue({
Expand Down Expand Up @@ -1128,7 +1131,7 @@ describe('importDeclarativeDefinitions', () => {
return {}
})

await importDeclarativeDefinitions({
const result = await importDeclarativeDefinitions({
remoteApp: {
apiKey: 'test-api-key',
apiSecretKeys: [{secret: 'test-secret'}],
Expand All @@ -1139,7 +1142,15 @@ describe('importDeclarativeDefinitions', () => {
appConfiguration: {},
} as any)

expect(outputMock.info()).toMatchInlineSnapshot(`""`)
expect(result).toEqual({
status: 'success',
storeDomain: 'test-shop.myshopify.com',
tomlContent: '',
metafieldCount: 0,
metaobjectCount: 0,
skippedSections: [],
})
expect(outputMock.info()).toBe('')
outputMock.clear()
})

Expand Down Expand Up @@ -1316,10 +1327,12 @@ describe('importDeclarativeDefinitions', () => {
},
}

await importDeclarativeDefinitions({
const filteredResult = await importDeclarativeDefinitions({
...options,
includeExistingDeclaredDefinitions: false,
} as any)
expect(filteredResult).toMatchObject({metafieldCount: 1, metaobjectCount: 1, skippedSections: []})
renderImportDeclarativeDefinitionsResult(filteredResult)

expect(outputMock.info()).toMatchInlineSnapshot(`
"# type: $app:new
Expand All @@ -1333,10 +1346,12 @@ describe('importDeclarativeDefinitions', () => {
`)
outputMock.clear()

await importDeclarativeDefinitions({
const fullResult = await importDeclarativeDefinitions({
...options,
includeExistingDeclaredDefinitions: true,
} as any)
expect(fullResult).toMatchObject({metafieldCount: 2, metaobjectCount: 2, skippedSections: []})
renderImportDeclarativeDefinitionsResult(fullResult)

expect(outputMock.info()).toMatchInlineSnapshot(`
"# type: $app:existing
Expand Down
Loading
Loading