Skip to content
Merged
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/deploy-config-with-client-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': minor
---

Allow `--config` and `--client-id` together on `app deploy`, `app dev`, and `app dev clean` to use the selected configuration with a different app
12 changes: 6 additions & 6 deletions docs-shopify.dev/generated/generated_docs_data_v2.json

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions packages/app/src/cli/commands/app/deploy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Deploy from './deploy.js'
import {deploy} from '../../services/deploy.js'
import {linkedAppContext} from '../../services/app-context.js'
import {testAppLinked, testOrganizationApp} from '../../models/app/app.test-data.js'
import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs'
import {beforeEach, describe, expect, test, vi} from 'vitest'

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

describe('app deploy command', () => {
beforeEach(() => {
vi.mocked(linkedAppContext).mockReset()
const app = testAppLinked()
vi.mocked(linkedAppContext).mockResolvedValue({
app,
remoteApp: testOrganizationApp(),
} as Awaited<ReturnType<typeof linkedAppContext>>)
vi.mocked(deploy).mockResolvedValue({app} as Awaited<ReturnType<typeof deploy>>)
})

test('accepts --config together with --client-id to deploy a configuration to a different app', async () => {
await inTemporaryDirectory(async (tmp) => {
await Deploy.run(
['--path', tmp, '--config', 'prod', '--client-id', 'a-different-app', '--allow-updates'],
import.meta.url,
)

expect(linkedAppContext).toHaveBeenCalledWith({
directory: tmp,
clientId: 'a-different-app',
forceRelink: false,
userProvidedConfigName: 'prod',
})
})
})

test('still rejects --reset together with --config', async () => {
await inTemporaryDirectory(async (tmp) => {
await expect(
Deploy.run(['--path', tmp, '--config', 'prod', '--reset', '--allow-updates'], import.meta.url),
).rejects.toThrow()

expect(linkedAppContext).not.toHaveBeenCalled()
})
})
})
8 changes: 8 additions & 0 deletions packages/app/src/cli/commands/app/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ export default class Deploy extends AppLinkedCommand {
static flags = {
...globalFlags,
...appFlags,
// Unlike the shared app flag, deploy accepts --client-id together with --config:
// the configuration selected by --config is deployed to the app identified by --client-id.
'client-id': Flags.string({
hidden: false,
description:
'The Client ID of your app. Use with --config to deploy that configuration to a different app than the one it is linked to.',
env: 'SHOPIFY_FLAG_CLIENT_ID',
}),
'allow-updates': Flags.boolean({
hidden: false,
description:
Expand Down
40 changes: 40 additions & 0 deletions packages/app/src/cli/commands/app/dev.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import Dev from './dev.js'
import DevClean from './dev/clean.js'
import {devClean} from '../../services/dev-clean.js'
import {dev} from '../../services/dev.js'
import {linkedAppContext} from '../../services/app-context.js'
import {storeContext} from '../../services/store-context.js'
Expand All @@ -17,6 +19,7 @@ import {addPublicMetadata} from '@shopify/cli-kit/node/metadata'
import {beforeEach, describe, expect, test, vi} from 'vitest'

vi.mock('../../services/dev.js')
vi.mock('../../services/dev-clean.js')
vi.mock('../../services/app-context.js')
vi.mock('../../services/store-context.js')
vi.mock('../../services/dev/tunnel-mode.js')
Expand Down Expand Up @@ -51,6 +54,42 @@ describe('app dev command', () => {
return {store}
}

test.each([
['app dev', Dev, dev],
['app dev clean', DevClean, devClean],
])('%s accepts --config together with --client-id', async (_name, command, service) => {
await inTemporaryDirectory(async (tmp) => {
const {store} = mockAppAndStore(tmp)
vi.mocked(getTunnelMode).mockResolvedValue({mode: 'auto'})

await command.run(
['--path', tmp, '--store', store.shopDomain, '--config', 'prod', '--client-id', 'a-different-app'],
import.meta.url,
)

expect(linkedAppContext).toHaveBeenCalledWith({
directory: tmp,
clientId: 'a-different-app',
forceRelink: false,
userProvidedConfigName: 'prod',
})
expect(service).toHaveBeenCalled()
})
})

test.each([
['app dev', Dev],
['app dev clean', DevClean],
])('%s still rejects --reset together with --config', async (_name, command) => {
await inTemporaryDirectory(async (tmp) => {
await expect(command.run(['--path', tmp, '--config', 'prod', '--reset'], import.meta.url)).rejects.toThrow()

expect(linkedAppContext).not.toHaveBeenCalled()
expect(dev).not.toHaveBeenCalled()
expect(devClean).not.toHaveBeenCalled()
})
})

test('does not require --use-localhost when --install-mkcert is not passed', async () => {
await inTemporaryDirectory(async (tmp) => {
const {store} = mockAppAndStore(tmp)
Expand Down Expand Up @@ -113,6 +152,7 @@ describe('app dev command', () => {
await expect(Dev.run(['--path', tmp, '--install-mkcert'], import.meta.url)).rejects.toThrow()

expect(dev).not.toHaveBeenCalled()
expect(devClean).not.toHaveBeenCalled()
})
})
})
7 changes: 7 additions & 0 deletions packages/app/src/cli/commands/app/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ export default class Dev extends AppLinkedCommand {
static flags = {
...globalFlags,
...appFlags,
// The selected configuration is used with the app identified by --client-id.
'client-id': Flags.string({
hidden: false,
description:
'The Client ID of your app. Use with --config to run dev with that configuration for a different app than the one it is linked to.',
env: 'SHOPIFY_FLAG_CLIENT_ID',
}),
store: Flags.string({
char: 's',
description: 'Store URL. Must be an existing development or Shopify Plus sandbox store.',
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/cli/commands/app/dev/clean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export default class DevClean extends AppLinkedCommand {
static flags = {
...globalFlags,
...appFlags,
'client-id': Flags.string({
hidden: false,
description:
'The Client ID of your app. Use with --config to clean up the dev preview for a different app than the one it is linked to.',
env: 'SHOPIFY_FLAG_CLIENT_ID',
}),
store: Flags.string({
hidden: false,
char: 's',
Expand Down
5 changes: 5 additions & 0 deletions packages/app/src/cli/models/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ export interface AppConfigurationInterface<
directory: string
configPath: string
configuration: TConfig
/** The client ID provided by the command, preserved when the app reloads. */
readonly clientIdOverride?: string
configSchema: SchemaForConfig<TConfig>
specifications: TModuleSpec[]
remoteFlags: Flag[]
Expand Down Expand Up @@ -277,6 +279,7 @@ export class App<
directory: string
configPath: string
configuration: TConfig
readonly clientIdOverride?: string
webs: Web[]
dotenv?: DotEnvFile
errors: AppErrors
Expand All @@ -292,6 +295,7 @@ export class App<
directory,
configPath,
configuration,
clientIdOverride,
webs,
modules,
dotenv,
Expand All @@ -306,6 +310,7 @@ export class App<
this.directory = directory
this.configPath = configPath
this.configuration = configuration
this.clientIdOverride = clientIdOverride
this.webs = webs
this.dotenv = dotenv
this.realExtensions = modules
Expand Down
5 changes: 4 additions & 1 deletion packages/app/src/cli/models/app/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export function parseConfigurationObject<TSchema extends zod.ZodType>(
/**
* Parses a configuration object using a specification's schema, and returns a result.
*/
export function parseConfigurationObjectAgainstSpecification<TSchema extends zod.ZodType>(
function parseConfigurationObjectAgainstSpecification<TSchema extends zod.ZodType>(
spec: ExtensionSpecification,
filepath: string,
configurationObject: object,
Expand Down Expand Up @@ -340,6 +340,7 @@ export async function loadAppFromContext<TModuleSpec extends ExtensionSpecificat
configPath: configurationPath,
configuration,
configurationLoadResultMetadata,
clientIdOverride,
configSchema,
specifications,
remoteFlags,
Expand Down Expand Up @@ -456,6 +457,7 @@ export async function reloadApp(app: AppLinkedInterface): Promise<AppLinkedInter
specifications: app.specifications,
remoteFlags: app.remoteFlags ?? [],
reloadState,
clientIdOverride: app.clientIdOverride,
})
if (!newApp.errors.isEmpty()) {
const errors = newApp.errors.getErrors()
Expand Down Expand Up @@ -529,6 +531,7 @@ class AppLoader<TConfig extends CurrentAppConfiguration, TModuleSpec extends Ext
directory,
configPath,
configuration,
clientIdOverride: this.loadedConfiguration.clientIdOverride,
webs,
modules: extensions,
dotenv,
Expand Down
148 changes: 147 additions & 1 deletion packages/app/src/cli/services/app-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {fetchSpecifications} from './generate/fetch-extension-specifications.js'
import {addUidToTomlsIfNecessary} from './app/add-uid-to-extension-toml.js'
import link from './app/config/link.js'
import {appFromIdentifiers} from './context.js'
import {handleWatcherEvents} from './dev/app-events/app-event-watcher-handler.js'

import * as localStorage from './local-storage.js'
import {fetchOrgFromId} from './dev/fetch.js'
Expand All @@ -11,9 +12,10 @@ import metadata from '../metadata.js'
import * as loader from '../models/app/loader.js'
import {loadLocalExtensionsSpecifications} from '../models/extensions/load-specifications.js'
import {beforeEach, describe, expect, test, vi} from 'vitest'
import {inTemporaryDirectory, writeFile, mkdir} from '@shopify/cli-kit/node/fs'
import {inTemporaryDirectory, writeFile, mkdir, readFile} from '@shopify/cli-kit/node/fs'
import {joinPath, normalizePath} from '@shopify/cli-kit/node/path'
import {tryParseInt} from '@shopify/cli-kit/common/string'
import {AbortController} from '@shopify/cli-kit/node/abort'

vi.mock('../models/app/validation/multi-cli-warning.js')
vi.mock('./generate/fetch-extension-specifications.js')
Expand Down Expand Up @@ -145,6 +147,150 @@ client_id="test-api-key"`
})
})

test.each([true, false])(
'keeps the selected configuration and target client ID after reload (matching TOML: %s)',
async (hasMatchingConfig) => {
await inTemporaryDirectory(async (tmp) => {
const sourceConfig = `
name = "production-app"
client_id = "source-client-id"
application_url = "https://production.example.com"
embedded = true

[access_scopes]
scopes = "read_products"

[webhooks]
api_version = "2026-07"

[auth]
redirect_urls = ["https://production.example.com/auth"]
`
const targetConfig = `
name = "development-app"
client_id = "target-client-id"
application_url = "https://development.example.com"
embedded = true
`
const sourceConfigPath = joinPath(tmp, 'shopify.app.prod.toml')
const targetConfigPath = joinPath(tmp, 'shopify.app.dev.toml')
await writeAppConfig(tmp, sourceConfig, 'shopify.app.prod.toml')
if (hasMatchingConfig) {
await writeFile(targetConfigPath, targetConfig)
localStorage.setCachedAppInfo({directory: tmp, configFile: 'shopify.app.dev.toml'})
}
await mkdir(joinPath(tmp, '.shopify'))
await writeFile(
joinPath(tmp, '.shopify', 'project.json'),
JSON.stringify({
'source-client-id': {dev_store_url: 'source.myshopify.com'},
'target-client-id': {dev_store_url: 'target.myshopify.com'},
}),
)
const functionDirectory = joinPath(tmp, 'extensions', 'discount')
await mkdir(functionDirectory)
await writeFile(
joinPath(functionDirectory, 'shopify.extension.toml'),
`api_version = "2026-07"

[[extensions]]
name = "Discount"
handle = "discount"
type = "function"
`,
)
const {loadLocalExtensionsSpecifications: loadSpecifications} = await vi.importActual<
typeof import('../models/extensions/load-specifications.js')
>('../models/extensions/load-specifications.js')
vi.mocked(fetchSpecifications).mockResolvedValue(
(await loadSpecifications()).map((specification) => ({...specification, loadedRemoteSpecs: true})),
)
vi.mocked(appFromIdentifiers).mockResolvedValue({...mockRemoteApp, apiKey: 'target-client-id'})

const {app} = await linkedAppContext({
directory: tmp,
clientId: 'target-client-id',
forceRelink: false,
userProvidedConfigName: 'prod',
})

expect(appFromIdentifiers).toHaveBeenCalledWith({apiKey: 'target-client-id'})
expect(link).not.toHaveBeenCalled()
expect(app.configPath).toBe(normalizePath(sourceConfigPath))
expect(app.configuration).toEqual(
expect.objectContaining({
client_id: 'target-client-id',
name: 'production-app',
application_url: 'https://production.example.com',
access_scopes: {scopes: 'read_products'},
}),
)
expect(app.hiddenConfig.dev_store_url).toBe('target.myshopify.com')

const reloadedApp = await loader.reloadApp(app)

expect(reloadedApp.configPath).toBe(app.configPath)
expect(reloadedApp.configuration).toEqual(app.configuration)
expect(reloadedApp.hiddenConfig.dev_store_url).toBe('target.myshopify.com')
expect((await reloadedApp.manifest(undefined)).modules).toEqual(
expect.arrayContaining([
expect.objectContaining({
handle: 'discount',
config: expect.objectContaining({app_key: 'target-client-id'}),
}),
]),
)
expect(reloadedApp.clientIdOverride).toBe('target-client-id')
await expect(readFile(sourceConfigPath)).resolves.toBe(sourceConfig)
const updatedConfig = sourceConfig.replace('production-app', 'updated-app')
await writeFile(sourceConfigPath, updatedConfig)
const event = await handleWatcherEvents(
[{type: 'extensions_config_updated', path: sourceConfigPath, extensionPath: tmp, startTime: [0, 0]}],
reloadedApp,
{stdout: process.stdout, stderr: process.stderr, signal: new AbortController().signal},
)
expect(event?.appWasReloaded).toBe(true)
expect(event?.app.configuration.client_id).toBe('target-client-id')
expect(event?.app.configuration.name).toBe('updated-app')
expect(event?.app.clientIdOverride).toBe('target-client-id')
expect(event?.app.hiddenConfig.dev_store_url).toBe('target.myshopify.com')
expect((await event!.app.manifest(undefined)).modules).toEqual(
expect.arrayContaining([
expect.objectContaining({
handle: 'discount',
config: expect.objectContaining({app_key: 'target-client-id'}),
}),
]),
)
await expect(readFile(sourceConfigPath)).resolves.toBe(updatedConfig)
if (hasMatchingConfig) await expect(readFile(targetConfigPath)).resolves.toBe(targetConfig)
})
},
)

test.each([
{clientId: undefined, expectedClientId: 'updated-client-id'},
{clientId: 'source-client-id', expectedClientId: 'source-client-id'},
])('reload uses $expectedClientId when the provided client ID is $clientId', async ({clientId, expectedClientId}) => {
await inTemporaryDirectory(async (tmp) => {
const content = 'name = "test-app"\nclient_id = "source-client-id"\n'
await writeAppConfig(tmp, content)
vi.mocked(appFromIdentifiers).mockResolvedValue({...mockRemoteApp, apiKey: 'source-client-id'})
const {app} = await linkedAppContext({
directory: tmp,
clientId,
forceRelink: false,
userProvidedConfigName: undefined,
})
await writeAppConfig(tmp, content.replace('source-client-id', 'updated-client-id'))

const reloadedApp = await loader.reloadApp(app)

expect(reloadedApp.configuration.client_id).toBe(expectedClientId)
expect(reloadedApp.clientIdOverride).toBe(clientId)
})
})

test('resets app when there is a valid toml but reset option is true', async () => {
await inTemporaryDirectory(async (tmp) => {
// Given
Expand Down
Loading
Loading