Skip to content
Draft
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/json-app-channel-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli': major
---

Return a success status and absolute file path in `app import channel-config --json`.
7 changes: 6 additions & 1 deletion bin/prettify-manifests.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ const manifestFiles = glob.sync(`packages/*/oclif.manifest.json`)
for (const file of manifestFiles) {
console.log(`Prettifying ${file}...`)
const content = fs.readFileSync(file)
const prettyContent = stringify(JSON.parse(content), {space: ' '}).replaceAll(root, '.')
const manifest = JSON.parse(content)
for (const command of Object.values(manifest.commands)) {
// Runtime schemas belong to command classes, not cached metadata.
delete command.jsonOutputSchema
}
const prettyContent = stringify(manifest, {space: ' '}).replaceAll(root, '.')
fs.writeFileSync(file, prettyContent)
}
65 changes: 65 additions & 0 deletions packages/app/src/cli/commands/app/import/channel-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import ImportChannelConfig from './channel-config.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {importChannelConfigJsonOutputSchema} from '../../../services/import-channel-config/types.js'
import {
CHANNEL_SPEC_DIRECTORY,
CHANNEL_SPEC_EXTENSION_DIRECTORY,
} from '../../../services/import-channel-config/import.js'
import {testAppLinked, testDeveloperPlatformClient, testOrganizationApp} from '../../../models/app/app.test-data.js'
import {Config} from '@oclif/core'
import {inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs'
import {dirname, joinPath} from '@shopify/cli-kit/node/path'
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
import {expect, test, vi} from 'vitest'
import {fileURLToPath} from 'node:url'

vi.mock('../../../services/app-context.js')

const TOML = 'handle = "example"\nlabel = "Café channel"\n'
const warning = {code: 'missing_countries', message: 'Add a countries section.'}

test('imports real files and writes one JSON document with warning events on stderr', async () => {
await inTemporaryDirectory(async (tmp) => {
const app = testAppLinked({directory: tmp})
const developerPlatformClient = testDeveloperPlatformClient({
channelSpecExport: async () => ({
ok: true,
status: 200,
body: {handle: 'example', filename: 'example.toml', toml: TOML, warnings: [{...warning, internal: 'private'}]},
}),
})
vi.mocked(linkedAppContext).mockResolvedValue({
app,
remoteApp: testOrganizationApp(),
developerPlatformClient,
} as unknown as Awaited<ReturnType<typeof linkedAppContext>>)
await withCapturedStandardStreams(async ({stdout, stderr}) => {
await runCommand(['--path', tmp, '--json'])
const path = joinPath(tmp, CHANNEL_SPEC_DIRECTORY, 'example.toml')
const result = JSON.parse(stdout())
expect(result).toEqual({
status: 'success',
handle: 'example',
filename: 'example.toml',
path,
toml: TOML,
warnings: [warning],
})
expect(() =>
importChannelConfigJsonOutputSchema.encode({...result, path: joinPath(CHANNEL_SPEC_DIRECTORY, 'example.toml')}),
).toThrow()
expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'warning', message: warning.message})
await expect(readFile(path)).resolves.toBe(TOML)
await expect(readFile(joinPath(tmp, CHANNEL_SPEC_EXTENSION_DIRECTORY, 'shopify.extension.toml'))).resolves.toBe(
'name = "Channel config"\ntype = "channel_config"\nhandle = "channel-config"\n',
)
})
})
})

async function runCommand(argv: string[]) {
const config = await Config.load({root: joinPath(dirname(fileURLToPath(import.meta.url)), '../../../../..')})
// The installed CLI bundles this source package instead of loading it as a custom plugin.
config.plugins.delete('@shopify/app')
return ImportChannelConfig.run(argv, config)
}
16 changes: 10 additions & 6 deletions packages/app/src/cli/commands/app/import/channel-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {linkedAppContext} from '../../../services/app-context.js'
import {importChannelConfig} from '../../../services/import-channel-config/import.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
import {importChannelConfigJsonOutputSchema} from '../../../services/import-channel-config/types.js'
import {renderImportChannelConfigResult} from '../../../services/import-channel-config/result.js'
import {Flags} from '@oclif/core'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'

Expand All @@ -16,11 +17,9 @@ export default class ImportChannelConfig extends AppLinkedCommand {

The generated TOML only includes public \`channel_config\` fields. Review it, then deploy it with \`shopify app deploy\`.`

static description = this.descriptionWithoutMarkdown()
static jsonOutputSchema = importChannelConfigJsonOutputSchema

static get jsonOutputSchema() {
return importChannelConfigJsonOutputSchema
}
static description = this.descriptionForHelp()

static flags = {
...globalFlags,
Expand All @@ -43,14 +42,19 @@ export default class ImportChannelConfig extends AppLinkedCommand {
userProvidedConfigName: flags.config,
})

await importChannelConfig({
const result = await importChannelConfig({
app,
remoteApp,
developerPlatformClient,
force: flags.force,
json: flags.json,
})

renderImportChannelConfigResult(
result,
{directory: app.directory, name: remoteApp.title},
flags.json ? 'json' : 'text',
)

return {app}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,12 @@ function successResult(warnings: {code: string; message: string}[] = []) {
}
}

function testOptions(app: AppLinkedInterface, {force = false, json = false} = {}) {
function testOptions(app: AppLinkedInterface, {force = false} = {}) {
return {
app,
remoteApp: testOrganizationApp(),
developerPlatformClient: testDeveloperPlatformClient(),
force,
json,
}
}

Expand All @@ -47,7 +46,6 @@ describe('importChannelConfig', () => {
// Given
vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult())
const app = testAppLinked({directory: tmpDir})
const outputMock = mockAndCaptureOutput()

// When
await importChannelConfig(testOptions(app))
Expand All @@ -56,9 +54,6 @@ describe('importChannelConfig', () => {
const outputPath = joinPath(tmpDir, CHANNEL_SPEC_DIRECTORY, 'example.toml')
await expect(fileExists(outputPath)).resolves.toBe(true)
await expect(readFile(outputPath)).resolves.toEqual(TOML)
expect(outputMock.info()).toContain('Imported the channel spec')
expect(outputMock.info()).toContain('shopify app dev')
expect(outputMock.info()).toContain('shopify app deploy')
})
})

Expand Down Expand Up @@ -94,7 +89,7 @@ describe('importChannelConfig', () => {
})
})

test('renders backend warnings when writing the file', async () => {
test('returns backend warnings when writing the file', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given
const warning = {
Expand All @@ -104,13 +99,12 @@ describe('importChannelConfig', () => {
}
vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult([warning]))
const app = testAppLinked({directory: tmpDir})
const outputMock = mockAndCaptureOutput()

// When
await importChannelConfig(testOptions(app))
const result = await importChannelConfig(testOptions(app))

// Then
expect(outputMock.warn()).toContain('This generated spec enables automatic product feed management.')
expect(result.warnings).toEqual([warning])
await expect(readFile(joinPath(tmpDir, CHANNEL_SPEC_DIRECTORY, 'example.toml'))).resolves.not.toContain(
'product feed management',
)
Expand Down Expand Up @@ -141,31 +135,6 @@ describe('importChannelConfig', () => {
})
})

test('emits the encoded JSON result and still writes the file in --json mode', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given
const warning = {code: 'missing_countries', message: 'Add a countries section.'}
vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult([warning]))
const app = testAppLinked({directory: tmpDir})
const outputMock = mockAndCaptureOutput()

// When
await importChannelConfig(testOptions(app, {json: true}))

// Then
const outputPath = joinPath(tmpDir, CHANNEL_SPEC_DIRECTORY, 'example.toml')
await expect(fileExists(outputPath)).resolves.toBe(true)
const parsed = JSON.parse(outputMock.info())
expect(parsed).toEqual({
handle: 'example',
filename: 'example.toml',
path: joinPath(CHANNEL_SPEC_DIRECTORY, 'example.toml'),
toml: TOML,
warnings: [warning],
})
})
})

test('confines the write to the specifications directory when the filename contains path segments', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given
Expand All @@ -187,15 +156,13 @@ describe('importChannelConfig', () => {
// Given
vi.mocked(fetchChannelSpecExport).mockResolvedValue(successResult())
const app = testAppLinked({directory: tmpDir})
const outputMock = mockAndCaptureOutput()

// When
await importChannelConfig(testOptions(app))

// Then
const extensionConfigPath = joinPath(tmpDir, CHANNEL_SPEC_EXTENSION_DIRECTORY, 'shopify.extension.toml')
await expect(readFile(extensionConfigPath)).resolves.toContain('type = "channel_config"')
expect(outputMock.info()).toContain('shopify.extension.toml')
})
})

Expand All @@ -212,16 +179,15 @@ describe('importChannelConfig', () => {
directory: tmpDir,
allExtensions: [await testChannelConfigExtension(extensionDirectory, 'my-channel')],
})
const outputMock = mockAndCaptureOutput()

// When
await importChannelConfig(testOptions(app))
const result = await importChannelConfig(testOptions(app))

// Then
await expect(readFile(joinPath(extensionDirectory, 'specifications', 'example.toml'))).resolves.toEqual(TOML)
await expect(readFile(extensionConfigPath)).resolves.toEqual(existingContent)
await expect(fileExists(joinPath(tmpDir, CHANNEL_SPEC_EXTENSION_DIRECTORY))).resolves.toBe(false)
expect(outputMock.info()).not.toContain('Also created')
expect(result.extensionConfigurationPath).toBeNull()
})
})

Expand Down
55 changes: 13 additions & 42 deletions packages/app/src/cli/services/import-channel-config/import.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {fetchChannelSpecExport} from './fetch.js'
import {importChannelConfigJsonOutputSchema} from './types.js'
import {ImportedChannelConfig} from './types.js'
import {AppLinkedInterface} from '../../models/app/app.js'
import {
CHANNEL_CONFIG_IDENTIFIER,
Expand All @@ -9,9 +9,7 @@ import {OrganizationApp} from '../../models/organization.js'
import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js'
import {AbortError} from '@shopify/cli-kit/node/error'
import {fileExists, matchGlob, mkdir, writeFile} from '@shopify/cli-kit/node/fs'
import {basename, dirname, joinPath, relativePath} from '@shopify/cli-kit/node/path'
import {outputResult} from '@shopify/cli-kit/node/output'
import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui'
import {basename, dirname, joinPath, relativePath, resolvePath} from '@shopify/cli-kit/node/path'

export const CHANNEL_SPEC_EXTENSION_DIRECTORY = joinPath('extensions', 'channel-config')
export const CHANNEL_SPEC_DIRECTORY = joinPath(
Expand All @@ -36,15 +34,14 @@ interface ImportChannelConfigOptions {
remoteApp: OrganizationApp
developerPlatformClient: DeveloperPlatformClient
force: boolean
json: boolean
}

/**
* Imports the Shopify-authored channel spec for the linked app into the app's channel_config
* extension, creating the extension if the app doesn't have one yet.
*/
export async function importChannelConfig(options: ImportChannelConfigOptions): Promise<void> {
const {app, remoteApp, developerPlatformClient, force, json} = options
export async function importChannelConfig(options: ImportChannelConfigOptions): Promise<ImportedChannelConfig> {
const {app, remoteApp, developerPlatformClient, force} = options

const result = await fetchChannelSpecExport({remoteApp, developerPlatformClient})

Expand All @@ -57,7 +54,7 @@ export async function importChannelConfig(options: ImportChannelConfigOptions):
const {extensionDirectory, createExtension} = resolveExtensionDirectory(app)

// basename() so a filename with path separators can't escape the specifications directory
const outputPath = joinPath(extensionDirectory, CHANNEL_CONFIG_SPECIFICATIONS_DIRECTORY, basename(result.filename))
const outputPath = resolvePath(extensionDirectory, CHANNEL_CONFIG_SPECIFICATIONS_DIRECTORY, basename(result.filename))
if (!force && (await fileExists(outputPath))) {
throw new AbortError(
`A channel spec already exists at ${relativePath(app.directory, outputPath)}.`,
Expand All @@ -71,41 +68,15 @@ export async function importChannelConfig(options: ImportChannelConfigOptions):
}
await writeFile(outputPath, result.toml)

if (json) {
outputResult(
importChannelConfigJsonOutputSchema.encode({
handle: result.handle,
filename: basename(result.filename),
path: relativePath(app.directory, outputPath),
toml: result.toml,
warnings: result.warnings,
}),
)
return
return {
status: 'success',
handle: result.handle,
filename: basename(result.filename),
path: outputPath,
toml: result.toml,
warnings: result.warnings,
extensionConfigurationPath: createExtension ? resolvePath(extensionDirectory, EXTENSION_CONFIG_FILENAME) : null,
}

result.warnings.forEach((warning) => renderWarning({body: warning.message}))

renderSuccess({
headline: ['Imported the channel spec for', {userInput: remoteApp.title}, {char: '.'}],
body: [
'The spec was written to',
{filePath: relativePath(app.directory, outputPath)},
{char: '.'},
...(createExtension
? [
'Also created',
{filePath: relativePath(app.directory, joinPath(extensionDirectory, EXTENSION_CONFIG_FILENAME))},
'so the spec is included when your app is deployed.',
]
: []),
],
nextSteps: [
'Review the generated spec and make any changes your channel needs.',
['Run', {command: 'shopify app dev'}, 'to try the spec on a development store before releasing it.'],
['Run', {command: 'shopify app deploy'}, 'to deploy the spec as part of your app.'],
],
})
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {renderImportChannelConfigResult} from './result.js'
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
import {unstyled} from '@shopify/cli-kit/node/output'
import {resolvePath, joinPath} from '@shopify/cli-kit/node/path'
import {expect, test} from 'vitest'

const directory = resolvePath('example-app')
const result = {
status: 'success' as const,
handle: 'example',
filename: 'example.toml',
path: joinPath(directory, 'extensions/channel-config/specifications/example.toml'),
toml: 'handle = "example"\nlabel = "Café channel"\n',
warnings: [{code: 'missing_countries', message: 'Add a countries section.'}],
extensionConfigurationPath: joinPath(directory, 'extensions/channel-config/shopify.extension.toml'),
}
const app = {directory, name: 'Example app'}

test('keeps the warning and success text on stderr with relative file paths', async () => {
await withCapturedStandardStreams(async ({stdout, stderr}) => {
renderImportChannelConfigResult(result, app, 'text')
expect(stdout()).toBe('')
const text = unstyled(stderr())
expect(text).toContain('Add a countries section.')
expect(text).toContain('Imported the channel spec for Example app.')
expect(text).toContain('Also created')
expect(text).toContain('extensions/channel-config/shopify.extension.toml')
expect(text).toContain('shopify app dev')
expect(text).toContain('shopify app deploy')
})
})

test('omits the created-extension text when the extension already exists', async () => {
await withCapturedStandardStreams(async ({stderr}) => {
renderImportChannelConfigResult({...result, extensionConfigurationPath: null}, app, 'text')
expect(unstyled(stderr())).not.toContain('Also created')
})
})
Loading
Loading