diff --git a/.changeset/deploy-config-with-client-id.md b/.changeset/deploy-config-with-client-id.md new file mode 100644 index 00000000000..5c481dd7897 --- /dev/null +++ b/.changeset/deploy-config-with-client-id.md @@ -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 diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 9931a16c7bf..e32fa234c82 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -908,7 +908,7 @@ "syntaxKind": "PropertySignature", "name": "--client-id ", "value": "string", - "description": "The Client ID of your app.", + "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.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" }, @@ -1021,7 +1021,7 @@ "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" } ], - "value": "export interface appdeploy {\n /**\n * Allows removing extensions and configuration without requiring user confirmation. For CI/CD environments, the recommended flag is --allow-updates. In non-interactive environments, provide this flag, --allow-updates, or --no-release.\n * @environment SHOPIFY_FLAG_ALLOW_DELETES\n */\n '--allow-deletes'?: ''\n\n /**\n * Allows adding and updating extensions and configuration without requiring user confirmation. Recommended option for CI/CD environments. In non-interactive environments, provide this flag, --allow-deletes, or --no-release.\n * @environment SHOPIFY_FLAG_ALLOW_UPDATES\n */\n '--allow-updates'?: ''\n\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Optional message that will be associated with this version. This is for internal use only and won't be available externally.\n * @environment SHOPIFY_FLAG_MESSAGE\n */\n '--message '?: string\n\n /**\n * Use with caution: Skips building any elements of the app that require building. You should ensure your app has been prepared in advance, such as by running `shopify app build` or by caching build artifacts.\n * @environment SHOPIFY_FLAG_NO_BUILD\n */\n '--no-build'?: ''\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 * Creates a version but doesn't release it - it's not made available to merchants. With this flag, a user confirmation is not required. In non-interactive environments, provide this flag, --allow-updates, or --allow-deletes.\n * @environment SHOPIFY_FLAG_NO_RELEASE\n */\n '--no-release'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * URL associated with the new app version.\n * @environment SHOPIFY_FLAG_SOURCE_CONTROL_URL\n */\n '--source-control-url '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * Optional version tag that will be associated with this app version. If not provided, an auto-generated identifier will be generated for this app version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" + "value": "export interface appdeploy {\n /**\n * Allows removing extensions and configuration without requiring user confirmation. For CI/CD environments, the recommended flag is --allow-updates. In non-interactive environments, provide this flag, --allow-updates, or --no-release.\n * @environment SHOPIFY_FLAG_ALLOW_DELETES\n */\n '--allow-deletes'?: ''\n\n /**\n * Allows adding and updating extensions and configuration without requiring user confirmation. Recommended option for CI/CD environments. In non-interactive environments, provide this flag, --allow-deletes, or --no-release.\n * @environment SHOPIFY_FLAG_ALLOW_UPDATES\n */\n '--allow-updates'?: ''\n\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app. Use with --config to deploy that configuration to a different app than the one it is linked to.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Optional message that will be associated with this version. This is for internal use only and won't be available externally.\n * @environment SHOPIFY_FLAG_MESSAGE\n */\n '--message '?: string\n\n /**\n * Use with caution: Skips building any elements of the app that require building. You should ensure your app has been prepared in advance, such as by running `shopify app build` or by caching build artifacts.\n * @environment SHOPIFY_FLAG_NO_BUILD\n */\n '--no-build'?: ''\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 * Creates a version but doesn't release it - it's not made available to merchants. With this flag, a user confirmation is not required. In non-interactive environments, provide this flag, --allow-updates, or --allow-deletes.\n * @environment SHOPIFY_FLAG_NO_RELEASE\n */\n '--no-release'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * URL associated with the new app version.\n * @environment SHOPIFY_FLAG_SOURCE_CONTROL_URL\n */\n '--source-control-url '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * Optional version tag that will be associated with this app version. If not provided, an auto-generated identifier will be generated for this app version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" } }, "appdevclean": { @@ -1045,7 +1045,7 @@ "syntaxKind": "PropertySignature", "name": "--client-id ", "value": "string", - "description": "The Client ID of your app.", + "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.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" }, @@ -1122,7 +1122,7 @@ "environmentValue": "SHOPIFY_FLAG_STORE" } ], - "value": "export interface appdevclean {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\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 '?: 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 store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: 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 appdevclean {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * 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.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\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 '?: 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 store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appdev": { @@ -1155,7 +1155,7 @@ "syntaxKind": "PropertySignature", "name": "--client-id ", "value": "string", - "description": "The Client ID of your app.", + "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.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" }, @@ -1340,7 +1340,7 @@ "environmentValue": "SHOPIFY_FLAG_THEME" } ], - "value": "export interface appdev {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * Resource URL for checkout UI extension. Format: \"/cart/{productVariantID}:{productQuantity}\"\n * @environment SHOPIFY_FLAG_CHECKOUT_CART_URL\n */\n '--checkout-cart-url '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Install and use mkcert to generate localhost certificates when --use-localhost is enabled without prompting.\n * @environment SHOPIFY_FLAG_INSTALL_MKCERT\n */\n '--install-mkcert'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Port to use for localhost. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_LOCALHOST_PORT\n */\n '--localhost-port '?: string\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 * Uses the app URL from the toml file instead an autogenerated URL for dev.\n * @environment SHOPIFY_FLAG_NO_UPDATE\n */\n '--no-update'?: ''\n\n /**\n * The file path or URL. The file path is to a file that you want updated on idle. The URL path is where you want a webhook posted to report on file changes.\n * @environment SHOPIFY_FLAG_NOTIFY\n */\n '--notify '?: string\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Skips the installation of dependencies. Deprecated, use workspaces instead.\n * @environment SHOPIFY_FLAG_SKIP_DEPENDENCIES_INSTALLATION\n */\n '--skip-dependencies-installation'?: ''\n\n /**\n * Store URL. Must be an existing development or Shopify Plus sandbox store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * Resource URL for subscription UI extension. Format: \"/products/{productId}\"\n * @environment SHOPIFY_FLAG_SUBSCRIPTION_PRODUCT_URL\n */\n '--subscription-product-url '?: string\n\n /**\n * Theme ID or name of the theme app extension host theme.\n * @environment SHOPIFY_FLAG_THEME\n */\n '-t, --theme '?: string\n\n /**\n * Local port of the theme app extension development server. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_THEME_APP_EXTENSION_PORT\n */\n '--theme-app-extension-port '?: string\n\n /**\n * Use a custom tunnel, it must be running before executing dev. Format: \"https://my-tunnel-url:port\".\n * @environment SHOPIFY_FLAG_TUNNEL_URL\n */\n '--tunnel-url '?: string\n\n /**\n * Allow app dev to continue with warnings for eligible app version validation failures.\n * @environment SHOPIFY_FLAG_UNSAFE_VALIDATION\n */\n '--unsafe-validation'?: ''\n\n /**\n * Service entry point will listen to localhost. A tunnel won't be used. Will work for testing many app features, but not those that directly invoke your app (E.g: Webhooks)\n * @environment SHOPIFY_FLAG_USE_LOCALHOST\n */\n '--use-localhost'?: ''\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 appdev {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * Resource URL for checkout UI extension. Format: \"/cart/{productVariantID}:{productQuantity}\"\n * @environment SHOPIFY_FLAG_CHECKOUT_CART_URL\n */\n '--checkout-cart-url '?: string\n\n /**\n * 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.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Install and use mkcert to generate localhost certificates when --use-localhost is enabled without prompting.\n * @environment SHOPIFY_FLAG_INSTALL_MKCERT\n */\n '--install-mkcert'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Port to use for localhost. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_LOCALHOST_PORT\n */\n '--localhost-port '?: string\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 * Uses the app URL from the toml file instead an autogenerated URL for dev.\n * @environment SHOPIFY_FLAG_NO_UPDATE\n */\n '--no-update'?: ''\n\n /**\n * The file path or URL. The file path is to a file that you want updated on idle. The URL path is where you want a webhook posted to report on file changes.\n * @environment SHOPIFY_FLAG_NOTIFY\n */\n '--notify '?: string\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Skips the installation of dependencies. Deprecated, use workspaces instead.\n * @environment SHOPIFY_FLAG_SKIP_DEPENDENCIES_INSTALLATION\n */\n '--skip-dependencies-installation'?: ''\n\n /**\n * Store URL. Must be an existing development or Shopify Plus sandbox store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * Resource URL for subscription UI extension. Format: \"/products/{productId}\"\n * @environment SHOPIFY_FLAG_SUBSCRIPTION_PRODUCT_URL\n */\n '--subscription-product-url '?: string\n\n /**\n * Theme ID or name of the theme app extension host theme.\n * @environment SHOPIFY_FLAG_THEME\n */\n '-t, --theme '?: string\n\n /**\n * Local port of the theme app extension development server. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_THEME_APP_EXTENSION_PORT\n */\n '--theme-app-extension-port '?: string\n\n /**\n * Use a custom tunnel, it must be running before executing dev. Format: \"https://my-tunnel-url:port\".\n * @environment SHOPIFY_FLAG_TUNNEL_URL\n */\n '--tunnel-url '?: string\n\n /**\n * Allow app dev to continue with warnings for eligible app version validation failures.\n * @environment SHOPIFY_FLAG_UNSAFE_VALIDATION\n */\n '--unsafe-validation'?: ''\n\n /**\n * Service entry point will listen to localhost. A tunnel won't be used. Will work for testing many app features, but not those that directly invoke your app (E.g: Webhooks)\n * @environment SHOPIFY_FLAG_USE_LOCALHOST\n */\n '--use-localhost'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appenvpull": { diff --git a/packages/app/src/cli/commands/app/deploy.test.ts b/packages/app/src/cli/commands/app/deploy.test.ts new file mode 100644 index 00000000000..75918029e0e --- /dev/null +++ b/packages/app/src/cli/commands/app/deploy.test.ts @@ -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>) + vi.mocked(deploy).mockResolvedValue({app} as Awaited>) + }) + + 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() + }) + }) +}) diff --git a/packages/app/src/cli/commands/app/deploy.ts b/packages/app/src/cli/commands/app/deploy.ts index 4eee47e7100..d22bdd58f49 100644 --- a/packages/app/src/cli/commands/app/deploy.ts +++ b/packages/app/src/cli/commands/app/deploy.ts @@ -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: diff --git a/packages/app/src/cli/commands/app/dev.test.ts b/packages/app/src/cli/commands/app/dev.test.ts index 5bd68660525..f14870d69fa 100644 --- a/packages/app/src/cli/commands/app/dev.test.ts +++ b/packages/app/src/cli/commands/app/dev.test.ts @@ -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' @@ -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') @@ -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) @@ -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() }) }) }) diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index 1b0de21e116..6fe12af0385 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -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.', diff --git a/packages/app/src/cli/commands/app/dev/clean.ts b/packages/app/src/cli/commands/app/dev/clean.ts index 6b87c55a849..c4c78c56772 100644 --- a/packages/app/src/cli/commands/app/dev/clean.ts +++ b/packages/app/src/cli/commands/app/dev/clean.ts @@ -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', diff --git a/packages/app/src/cli/models/app/app.ts b/packages/app/src/cli/models/app/app.ts index 73eec01f3fc..b101e37125b 100644 --- a/packages/app/src/cli/models/app/app.ts +++ b/packages/app/src/cli/models/app/app.ts @@ -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 specifications: TModuleSpec[] remoteFlags: Flag[] @@ -277,6 +279,7 @@ export class App< directory: string configPath: string configuration: TConfig + readonly clientIdOverride?: string webs: Web[] dotenv?: DotEnvFile errors: AppErrors @@ -292,6 +295,7 @@ export class App< directory, configPath, configuration, + clientIdOverride, webs, modules, dotenv, @@ -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 diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index 28bac00d9d4..8c240700171 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -140,7 +140,7 @@ export function parseConfigurationObject( /** * Parses a configuration object using a specification's schema, and returns a result. */ -export function parseConfigurationObjectAgainstSpecification( +function parseConfigurationObjectAgainstSpecification( spec: ExtensionSpecification, filepath: string, configurationObject: object, @@ -340,6 +340,7 @@ export async function loadAppFromContext { + 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 diff --git a/packages/app/src/cli/services/app-context.ts b/packages/app/src/cli/services/app-context.ts index 00d37b86fce..d2d84124a14 100644 --- a/packages/app/src/cli/services/app-context.ts +++ b/packages/app/src/cli/services/app-context.ts @@ -136,7 +136,7 @@ export async function linkedAppContext({ activeConfig, specifications, remoteFlags: remoteApp.flags, - clientIdOverride: clientId && clientId !== configClientId ? clientId : undefined, + clientIdOverride: clientId, }) if (!unsafeTolerateErrors && !localApp.errors.isEmpty()) { diff --git a/packages/app/src/cli/services/deploy.test.ts b/packages/app/src/cli/services/deploy.test.ts index 619710f1043..49e2063611c 100644 --- a/packages/app/src/cli/services/deploy.test.ts +++ b/packages/app/src/cli/services/deploy.test.ts @@ -808,6 +808,7 @@ describe('ImportExtensionsIfNeeded', () => { developerPlatformClient, extensions: mockExtensions, }) + expect(reloadApp).toHaveBeenCalledWith(app) expect(result).toBe(reloadedApp) }) diff --git a/packages/cli/README.md b/packages/cli/README.md index 88184dfa59d..e0dfaf08d02 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -637,9 +637,9 @@ Deploy your Shopify app. ``` USAGE - $ shopify app deploy [--auth-alias ] [--client-id | -c ] [--json-schema] [--message - ] [--no-build] [--no-color] [--no-input] [--no-release | --allow-updates | --allow-deletes] [--path ] - [--reset | ] [--source-control-url ] [--verbose] [--version ] + $ shopify app deploy [--auth-alias ] [--client-id ] [--json-schema] [--message ] + [--no-build] [--no-color] [--no-input] [--no-release | --allow-updates | --allow-deletes] [--path ] [--reset + | -c ] [--source-control-url ] [--verbose] [--version ] FLAGS -c, --config= @@ -662,7 +662,8 @@ FLAGS [env: SHOPIFY_FLAG_AUTH_ALIAS] --client-id= - The Client ID of your app. + 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] --json-schema @@ -732,11 +733,11 @@ Run the app. ``` USAGE - $ shopify app dev [--auth-alias ] [--checkout-cart-url ] [--client-id | -c ] + $ shopify app dev [--auth-alias ] [--checkout-cart-url ] [--client-id ] [--install-mkcert --use-localhost] [--json-schema] [--localhost-port ] [--no-color] [--no-input] - [--no-update] [--notify ] [--path ] [--reset | ] [--skip-dependencies-installation] [-s ] - [--store-password ] [--subscription-product-url ] [-t ] [--theme-app-extension-port ] - [--tunnel-url | ] [--unsafe-validation] [--verbose] + [--no-update] [--notify ] [--path ] [--reset | -c ] [--skip-dependencies-installation] [-s + ] [--store-password ] [--subscription-product-url ] [-t ] [--theme-app-extension-port + ] [--tunnel-url | ] [--unsafe-validation] [--verbose] FLAGS -c, --config= @@ -760,7 +761,8 @@ FLAGS [env: SHOPIFY_FLAG_CHECKOUT_CART_URL] --client-id= - The Client ID of your app. + 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] --install-mkcert @@ -846,8 +848,8 @@ Cleans up the dev preview from the selected store. ``` USAGE - $ shopify app dev clean [--auth-alias ] [--client-id | -c ] [--json-schema] [--no-color] - [--no-input] [--path ] [--reset | ] [-s ] [--verbose] + $ shopify app dev clean [--auth-alias ] [--client-id ] [--json-schema] [--no-color] [--no-input] + [--path ] [--reset | -c ] [-s ] [--verbose] FLAGS -c, --config= @@ -863,7 +865,8 @@ FLAGS [env: SHOPIFY_FLAG_AUTH_ALIAS] --client-id= - The Client ID of your app. + 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] --json-schema diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index a531ad3df4a..65c22767600 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -990,11 +990,8 @@ "type": "option" }, "client-id": { - "description": "The Client ID of your app.", + "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", - "exclusive": [ - "config" - ], "hasDynamicHelp": false, "hidden": false, "multiple": false, @@ -1144,11 +1141,8 @@ "type": "option" }, "client-id": { - "description": "The Client ID of your app.", + "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", - "exclusive": [ - "config" - ], "hasDynamicHelp": false, "hidden": false, "multiple": false, @@ -1372,11 +1366,8 @@ "type": "option" }, "client-id": { - "description": "The Client ID of your app.", + "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", - "exclusive": [ - "config" - ], "hasDynamicHelp": false, "hidden": false, "multiple": false,