diff --git a/.changeset/app-dev-non-interactive-flags.md b/.changeset/app-dev-non-interactive-flags.md new file mode 100644 index 00000000000..f59065d305c --- /dev/null +++ b/.changeset/app-dev-non-interactive-flags.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Add non-interactive `app dev` options: `--store-password` and `--install-mkcert`. diff --git a/docs-shopify.dev/commands/interfaces/app-dev.interface.ts b/docs-shopify.dev/commands/interfaces/app-dev.interface.ts index 82bc41c8b3f..388b1b107c3 100644 --- a/docs-shopify.dev/commands/interfaces/app-dev.interface.ts +++ b/docs-shopify.dev/commands/interfaces/app-dev.interface.ts @@ -22,6 +22,12 @@ export interface appdev { */ '-c, --config '?: string + /** + * Install and use mkcert to generate localhost certificates when --use-localhost is enabled without prompting. + * @environment SHOPIFY_FLAG_INSTALL_MKCERT + */ + '--install-mkcert'?: '' + /** * Port to use for localhost. Must be between 1 and 65535. * @environment SHOPIFY_FLAG_LOCALHOST_PORT @@ -70,6 +76,12 @@ export interface appdev { */ '-s, --store '?: string + /** + * The password for storefronts with password protection. + * @environment SHOPIFY_FLAG_STORE_PASSWORD + */ + '--store-password '?: string + /** * Resource URL for subscription UI extension. Format: "/products/{productId}" * @environment SHOPIFY_FLAG_SUBSCRIPTION_PRODUCT_URL diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 99f4ea1b5ed..ccb71368943 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -880,6 +880,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-dev.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--install-mkcert", + "value": "''", + "description": "Install and use mkcert to generate localhost certificates when --use-localhost is enabled without prompting.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_INSTALL_MKCERT" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-dev.interface.ts", "syntaxKind": "PropertySignature", @@ -943,6 +952,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_SKIP_DEPENDENCIES_INSTALLATION" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-dev.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--store-password ", + "value": "string", + "description": "The password for storefronts with password protection.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE_PASSWORD" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-dev.interface.ts", "syntaxKind": "PropertySignature", @@ -1016,7 +1034,7 @@ "environmentValue": "SHOPIFY_FLAG_THEME" } ], - "value": "export interface appdev {\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 * 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 * 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 * 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 * 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.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface appdev {\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 * 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 * 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 * 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.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appenvpull": { diff --git a/packages/app/src/cli/api/graphql/convert_dev_to_transfer_disabled_store.ts b/packages/app/src/cli/api/graphql/convert_dev_to_transfer_disabled_store.ts deleted file mode 100644 index 51976dccb1f..00000000000 --- a/packages/app/src/cli/api/graphql/convert_dev_to_transfer_disabled_store.ts +++ /dev/null @@ -1,30 +0,0 @@ -import {gql} from 'graphql-request' - -export const ConvertDevToTransferDisabledStoreQuery = gql` - mutation convertDevToTestStore($input: ConvertDevToTestStoreInput!) { - convertDevToTestStore(input: $input) { - convertedToTestStore - userErrors { - message - field - } - } - } -` - -export interface ConvertDevToTransferDisabledStoreVariables { - input: { - organizationID: number - shopId: string - } -} - -export interface ConvertDevToTransferDisabledSchema { - convertDevToTestStore: { - convertedToTestStore: boolean - userErrors: { - field: string[] - message: string - }[] - } -} diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index 17edc962a05..3bffab4d07d 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -57,6 +57,12 @@ export default class Dev extends AppLinkedCommand { default: false, exclusive: ['tunnel-url'], }), + 'install-mkcert': Flags.boolean({ + description: + 'Install and use mkcert to generate localhost certificates when --use-localhost is enabled without prompting.', + env: 'SHOPIFY_FLAG_INSTALL_MKCERT', + default: false, + }), 'localhost-port': portFlag({ description: 'Port to use for localhost.', env: 'SHOPIFY_FLAG_LOCALHOST_PORT', @@ -70,6 +76,10 @@ export default class Dev extends AppLinkedCommand { description: 'Local port of the theme app extension development server.', env: 'SHOPIFY_FLAG_THEME_APP_EXTENSION_PORT', }), + 'store-password': Flags.string({ + description: 'The password for storefronts with password protection.', + env: 'SHOPIFY_FLAG_STORE_PASSWORD', + }), notify: Flags.string({ description: '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.', @@ -134,9 +144,11 @@ export default class Dev extends AppLinkedCommand { checkoutCartUrl: flags['checkout-cart-url'], theme: flags.theme, themeExtensionPort: flags['theme-app-extension-port'], + storePassword: flags['store-password'], notify: flags.notify, graphiqlPort: flags['graphiql-port'], graphiqlKey: flags['graphiql-key'], + installMkcert: flags['install-mkcert'], tunnel: tunnelMode, } diff --git a/packages/app/src/cli/models/app/app.test-data.ts b/packages/app/src/cli/models/app/app.test-data.ts index 5d7eb1fb90e..a5191a536d3 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -42,7 +42,6 @@ import { import {AllAppExtensionRegistrationsQuerySchema} from '../../api/graphql/all_app_extension_registrations.js' import {AppDeploySchema, AppDeployVariables} from '../../api/graphql/app_deploy.js' import {ExtensionCreateSchema, ExtensionCreateVariables} from '../../api/graphql/extension_create.js' -import {ConvertDevToTransferDisabledStoreVariables} from '../../api/graphql/convert_dev_to_transfer_disabled_store.js' import { DevelopmentStorePreviewUpdateInput, DevelopmentStorePreviewUpdateSchema, @@ -1275,13 +1274,6 @@ const generateSignedUploadUrlResponse: AssetUrlSchema = { userErrors: [], } -const convertedToTransferDisabledStoreResponse = { - convertDevToTestStore: { - convertedToTestStore: true, - userErrors: [], - }, -} - const updateDeveloperPreviewResponse: DevelopmentStorePreviewUpdateSchema = { developmentStorePreviewUpdate: { app: { @@ -1395,8 +1387,6 @@ export function testDeveloperPlatformClient(stubs: Partial Promise.resolve(deployResponse), release: (_input: {app: MinimalAppIdentifiers; version: AppVersionIdentifiers}) => Promise.resolve(releaseResponse), generateSignedUploadUrl: (_app: MinimalAppIdentifiers) => Promise.resolve(generateSignedUploadUrlResponse), - convertToTransferDisabledStore: (_input: ConvertDevToTransferDisabledStoreVariables) => - Promise.resolve(convertedToTransferDisabledStoreResponse), updateDeveloperPreview: (_input: DevelopmentStorePreviewUpdateInput) => Promise.resolve(updateDeveloperPreviewResponse), appPreviewMode: (_input: FindAppPreviewModeVariables) => Promise.resolve(appPreviewModeResponse), diff --git a/packages/app/src/cli/prompts/dev.ts b/packages/app/src/cli/prompts/dev.ts index dcdbb7ec2c6..35e671f6fdc 100644 --- a/packages/app/src/cli/prompts/dev.ts +++ b/packages/app/src/cli/prompts/dev.ts @@ -111,15 +111,6 @@ export async function selectStorePrompt({ return currentStores.find((store) => store.shopId === id) } -export async function confirmConversionToTransferDisabledStorePrompt(): Promise { - return renderConfirmationPrompt({ - message: `Make this store transfer-disabled? For security, once you use a dev store to preview an app locally, the store can never be transferred to a merchant to use as a production store.`, - confirmationMessage: 'Yes, make this store transfer-disabled permanently', - cancellationMessage: 'No, select another store', - defaultValue: false, - }) -} - export async function appNamePrompt(currentName: string): Promise { return renderTextPrompt({ message: 'App name', diff --git a/packages/app/src/cli/services/app/config/link.test.ts b/packages/app/src/cli/services/app/config/link.test.ts index 821f518c807..fd47e8ee1a4 100644 --- a/packages/app/src/cli/services/app/config/link.test.ts +++ b/packages/app/src/cli/services/app/config/link.test.ts @@ -1256,6 +1256,25 @@ describe('link', () => { }) }) + test('when remote app exists and supports dev sessions then include automatically_update_urls_on_dev = true', async () => { + await inTemporaryDirectory(async (tmp) => { + // Given + const developerPlatformClient = buildDeveloperPlatformClient({supportsDevSessions: true}) + const options: LinkOptions = { + directory: tmp, + developerPlatformClient, + } + await mockLoadOpaqueAppWithApp(tmp) + vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient})) + + // When + const {configuration} = await link(options) + + // Then + expect(configuration.build?.automatically_update_urls_on_dev).toBe(true) + }) + }) + test('replace arrays content with the remote one', async () => { await inTemporaryDirectory(async (tmp) => { // Given diff --git a/packages/app/src/cli/services/app/config/link.ts b/packages/app/src/cli/services/app/config/link.ts index ae71e53c6ba..7740851d575 100644 --- a/packages/app/src/cli/services/app/config/link.ts +++ b/packages/app/src/cli/services/app/config/link.ts @@ -407,7 +407,7 @@ function buildOptionsForGeneratedConfigFile(options: { } = options const buildOptions = { ...(linkedAppWasNewlyCreated ? {include_config_on_deploy: true} : {}), - ...(defaultToUpdateUrlsOnDev && linkedAppWasNewlyCreated ? {automatically_update_urls_on_dev: true} : {}), + ...(defaultToUpdateUrlsOnDev ? {automatically_update_urls_on_dev: true} : {}), ...(linkedAppAndClientIdFromFileAreInSync ? existingBuildOptions : {}), } if (isEmpty(buildOptions)) { diff --git a/packages/app/src/cli/services/dev.ts b/packages/app/src/cli/services/dev.ts index 0d30481a791..fb8b249e561 100644 --- a/packages/app/src/cli/services/dev.ts +++ b/packages/app/src/cli/services/dev.ts @@ -68,9 +68,11 @@ export interface DevOptions { tunnel: TunnelMode theme?: string themeExtensionPort?: number + storePassword?: string notify?: string graphiqlPort?: number graphiqlKey?: string + installMkcert?: boolean } export async function dev(commandOptions: DevOptions) { @@ -152,6 +154,7 @@ async function prepareForDev(commandOptions: DevOptions): Promise { tunnel, tunnelClient, remoteApp.configuration, + commandOptions.installMkcert, ) app.webs = webs @@ -324,6 +327,7 @@ async function setupNetworkingOptions( tunnelOptions: TunnelMode, tunnelClient?: TunnelClient, remoteAppConfig?: AppConfigurationUsedByCli, + installMkcert?: boolean, ) { const {backendConfig, frontendConfig} = frontAndBackendConfig(webs) @@ -361,6 +365,7 @@ async function setupNetworkingOptions( if (tunnelOptions.mode === 'use-localhost') { const {keyContent, certContent, certPath} = await generateCertificate({ appDirectory, + install: installMkcert, }) reverseProxyCert = { diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts index 9feb2b29f07..708fefc1cd3 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts @@ -199,6 +199,7 @@ export async function setupDevProcesses({ storeFqdn, theme: commandOptions.theme, themeExtensionPort: commandOptions.themeExtensionPort, + storePassword: commandOptions.storePassword, }), setupSendUninstallWebhookProcess({ webs: reloadedApp.webs, diff --git a/packages/app/src/cli/services/dev/processes/theme-app-extension.test.ts b/packages/app/src/cli/services/dev/processes/theme-app-extension.test.ts index a2a93321aab..ff206c9aca1 100644 --- a/packages/app/src/cli/services/dev/processes/theme-app-extension.test.ts +++ b/packages/app/src/cli/services/dev/processes/theme-app-extension.test.ts @@ -204,6 +204,24 @@ describe('setupPreviewThemeAppExtensionsProcess', () => { expect(ensureValidPassword).toHaveBeenCalledWith(undefined, 'test.myshopify.com') expect(result!.options.storefrontPassword).toEqual(storefrontPassword) }) + + test('uses the provided store password when one is required', async () => { + const mockTheme = {id: 123} as Theme + vi.mocked(fetchTheme).mockResolvedValue(mockTheme) + vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(true) + vi.mocked(ensureValidPassword).mockResolvedValue('validated-password') + + const result = await setupPreviewThemeAppExtensionsProcess({ + localApp: testApp({allExtensions: [await testThemeExtensions()]}), + remoteApp: testOrganizationApp(), + storeFqdn: 'test.myshopify.com', + theme: '1', + storePassword: 'provided-password', + }) + + expect(ensureValidPassword).toHaveBeenCalledWith('provided-password', 'test.myshopify.com') + expect(result!.options.storefrontPassword).toEqual('validated-password') + }) }) describe('findOrCreateHostTheme', () => { diff --git a/packages/app/src/cli/services/dev/processes/theme-app-extension.ts b/packages/app/src/cli/services/dev/processes/theme-app-extension.ts index cda4751c29c..f4ab7cbf8ef 100644 --- a/packages/app/src/cli/services/dev/processes/theme-app-extension.ts +++ b/packages/app/src/cli/services/dev/processes/theme-app-extension.ts @@ -26,6 +26,7 @@ interface HostThemeSetupOptions { storeFqdn: string theme?: string themeExtensionPort?: number + storePassword?: string } export interface PreviewThemeAppExtensionsProcess extends BaseProcess { @@ -53,7 +54,7 @@ export async function setupPreviewThemeAppExtensionsProcess( const storeFqdn = adminSession.storeFqdn const storefrontPassword = (await isStorefrontPasswordProtected(adminSession)) - ? await ensureValidPassword(undefined, storeFqdn) + ? await ensureValidPassword(options.storePassword, storeFqdn) : undefined const theme = await findOrCreateHostTheme(adminSession, options.theme) diff --git a/packages/app/src/cli/services/dev/select-store.test.ts b/packages/app/src/cli/services/dev/select-store.test.ts index 9cf01784501..7356b639b41 100644 --- a/packages/app/src/cli/services/dev/select-store.test.ts +++ b/packages/app/src/cli/services/dev/select-store.test.ts @@ -1,10 +1,6 @@ import {selectStore} from './select-store.js' import {Organization, OrganizationSource, OrganizationStore} from '../../models/organization.js' -import { - reloadStoreListPrompt, - selectStorePrompt, - confirmConversionToTransferDisabledStorePrompt, -} from '../../prompts/dev.js' +import {reloadStoreListPrompt, selectStorePrompt} from '../../prompts/dev.js' import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js' import {ClientName} from '../../utilities/developer-platform-client.js' import {sleep} from '@shopify/cli-kit/node/system' @@ -94,51 +90,25 @@ describe('selectStore', async () => { ) }) - test('prompts user to convert store to non-transferable if selection is invalid', async () => { + test('throws if selected store is not transfer-disabled', async () => { // Given vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE2) - vi.mocked(confirmConversionToTransferDisabledStorePrompt).mockResolvedValueOnce(true) // When - const got = await selectStore( - {stores: [STORE1, STORE2], hasMorePages: false}, - ORG1, - testDeveloperPlatformClient({clientName: ClientName.Partners}), - ) - - // Then - expect(got).toEqual(STORE2) - expect(selectStorePrompt).toHaveBeenCalledWith( - expect.objectContaining({ - stores: [STORE1, STORE2], - showDomainOnPrompt: defaultShowDomainOnPrompt, - }), - ) - expect(confirmConversionToTransferDisabledStorePrompt).toHaveBeenCalled() - }) - - test('choosing not to convert to transfer-disabled forces another prompt', async () => { - // Given - vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE2) - vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE1) - vi.mocked(confirmConversionToTransferDisabledStorePrompt).mockResolvedValueOnce(false) - - // When - const got = await selectStore( + const got = selectStore( {stores: [STORE1, STORE2], hasMorePages: false}, ORG1, testDeveloperPlatformClient({clientName: ClientName.Partners}), ) // Then - expect(got).toEqual(STORE1) + await expect(got).rejects.toThrow('The store you specified (domain2) is not transfer-disabled') expect(selectStorePrompt).toHaveBeenCalledWith( expect.objectContaining({ stores: [STORE1, STORE2], showDomainOnPrompt: defaultShowDomainOnPrompt, }), ) - expect(confirmConversionToTransferDisabledStorePrompt).toHaveBeenCalled() }) test('throws if store is non convertible', async () => { diff --git a/packages/app/src/cli/services/dev/select-store.ts b/packages/app/src/cli/services/dev/select-store.ts index d02c8969ee0..1ba418db923 100644 --- a/packages/app/src/cli/services/dev/select-store.ts +++ b/packages/app/src/cli/services/dev/select-store.ts @@ -1,19 +1,10 @@ import {Organization, OrganizationStore} from '../../models/organization.js' -import { - confirmConversionToTransferDisabledStorePrompt, - reloadStoreListPrompt, - selectStorePrompt, -} from '../../prompts/dev.js' -import { - ConvertDevToTransferDisabledSchema, - ConvertDevToTransferDisabledStoreVariables, -} from '../../api/graphql/convert_dev_to_transfer_disabled_store.js' +import {reloadStoreListPrompt, selectStorePrompt} from '../../prompts/dev.js' import {ClientName, DeveloperPlatformClient, Paginateable} from '../../utilities/developer-platform-client.js' import {sleep} from '@shopify/cli-kit/node/system' import {renderInfo, renderTasks} from '@shopify/cli-kit/node/ui' import {firstPartyDev} from '@shopify/cli-kit/node/context/local' -import {AbortError, BugError, CancelExecution} from '@shopify/cli-kit/node/error' -import {outputSuccess} from '@shopify/cli-kit/node/output' +import {AbortError, CancelExecution} from '@shopify/cli-kit/node/error' /** * Select store from list or @@ -35,8 +26,8 @@ export async function selectStore( if (developerPlatformClient.supportsStoreSearch) { onSearchForStoresByName = async (term: string) => developerPlatformClient.devStoresForOrg(org.id, term) } - // If no stores, guide the developer through creating one - // Then, with a store selected, make sure its transfer-disabled, prompting to convert if needed + // If no stores, guide the developer through creating one. + // Then, with a store selected, make sure it's transfer-disabled. let store = await selectStorePrompt({ onSearchForStoresByName, ...storesSearch, @@ -57,21 +48,7 @@ export async function selectStore( store = await selectStore({stores, hasMorePages: false}, org, developerPlatformClient) } - let storeIsValid = await convertToTransferDisabledStoreIfNeeded( - store, - org.id, - developerPlatformClient, - 'prompt-first', - ) - while (!storeIsValid) { - // eslint-disable-next-line no-await-in-loop - store = await selectStorePrompt({stores: [store], hasMorePages: false, showDomainOnPrompt}) - if (!store) { - throw new CancelExecution() - } - // eslint-disable-next-line no-await-in-loop - storeIsValid = await convertToTransferDisabledStoreIfNeeded(store, org.id, developerPlatformClient, 'prompt-first') - } + ensureTransferDisabledStore(store) return store } @@ -114,23 +91,13 @@ async function waitForCreatedStore( /** * Check if the store exists in the current organization and it is a valid store - * To be valid, it must be non-transferable. This can't be undone, so we ask the user to confirm. + * To be valid, it must be transfer-disabled. * - * @param storeDomain - Store domain to check - * @param stores - List of available stores - * @param orgId - Current organization ID - * @param developerPlatformClient - The client to access the platform API - * @param conversionMode - Whether to prompt the user to convert the store to a transfer-disabled store, or fail if it's not - * @returns False, if the store is invalid and the user chose not to convert it. Otherwise true. - * @throws If the store can't be found in the organization or we fail to make it a transfer-disabled store + * @param store - Store to check + * @throws If the store is not eligible for `app dev` */ -export async function convertToTransferDisabledStoreIfNeeded( - store: OrganizationStore, - orgId: string, - developerPlatformClient: DeveloperPlatformClient, - conversionMode: 'prompt-first' | 'never', -): Promise { - if (store.transferDisabled || firstPartyDev()) return true +export function ensureTransferDisabledStore(store: OrganizationStore): void { + if (store.transferDisabled || firstPartyDev()) return if (!store.transferDisabled && !store.convertableToPartnerTest) { throw new AbortError( @@ -139,50 +106,8 @@ export async function convertToTransferDisabledStoreIfNeeded( ) } - switch (conversionMode) { - case 'prompt-first': { - const confirmed = await confirmConversionToTransferDisabledStorePrompt() - if (!confirmed) { - // tell caller the store is invalid and not converted. they may re-prompt etc. - return false - } - await convertStoreToTransferDisabled(store, orgId, developerPlatformClient) - return true - } - case 'never': { - throw new AbortError( - 'The store you specified is not transfer-disabled', - "Try running 'dev --reset' and selecting a different store, or choosing to convert this one.", - ) - } - } -} - -/** - * Convert a store to a transfer-disabled store so development apps can be installed - * @param store - Store to convert - * @param orgId - Current organization ID - * @param developerPlatformClient - The client to access the platform API - */ -async function convertStoreToTransferDisabled( - store: OrganizationStore, - orgId: string, - developerPlatformClient: DeveloperPlatformClient, -) { - const variables: ConvertDevToTransferDisabledStoreVariables = { - input: { - organizationID: parseInt(orgId, 10), - shopId: store.shopId, - }, - } - const result: ConvertDevToTransferDisabledSchema = - await developerPlatformClient.convertToTransferDisabledStore(variables) - if (!result.convertDevToTestStore.convertedToTestStore) { - const errors = result.convertDevToTestStore.userErrors.map((error) => error.message).join(', ') - throw new BugError( - `Error converting store ${store.shopDomain} to a transfer-disabled store: ${errors}`, - 'This store might not be compatible with draft apps, please try a different store', - ) - } - outputSuccess(`Converted ${store.shopDomain} to a transfer-disabled store`) + throw new AbortError( + `The store you specified (${store.shopDomain}) is not transfer-disabled`, + 'Run dev --reset and select a transfer-disabled dev store.', + ) } diff --git a/packages/app/src/cli/services/store-context.test.ts b/packages/app/src/cli/services/store-context.test.ts index bb83f8538da..7312aeb52c1 100644 --- a/packages/app/src/cli/services/store-context.test.ts +++ b/packages/app/src/cli/services/store-context.test.ts @@ -1,6 +1,6 @@ import {storeContext} from './store-context.js' import {fetchStore} from './dev/fetch.js' -import {convertToTransferDisabledStoreIfNeeded, selectStore} from './dev/select-store.js' +import {ensureTransferDisabledStore, selectStore} from './dev/select-store.js' import {LoadedAppContextOutput} from './app-context.js' import { testAppLinked, @@ -66,12 +66,7 @@ describe('storeContext', () => { mockDeveloperPlatformClient, ['APP_DEVELOPMENT'], ) - expect(convertToTransferDisabledStoreIfNeeded).toHaveBeenCalledWith( - mockStore, - mockOrganization.id, - mockDeveloperPlatformClient, - 'never', - ) + expect(ensureTransferDisabledStore).toHaveBeenCalledWith(mockStore) expect(result).toEqual(mockStore) }) }) diff --git a/packages/app/src/cli/services/store-context.ts b/packages/app/src/cli/services/store-context.ts index 42827d58bb1..05f9f9b3673 100644 --- a/packages/app/src/cli/services/store-context.ts +++ b/packages/app/src/cli/services/store-context.ts @@ -1,5 +1,5 @@ import {fetchStore} from './dev/fetch.js' -import {convertToTransferDisabledStoreIfNeeded, selectStore} from './dev/select-store.js' +import {ensureTransferDisabledStore, selectStore} from './dev/select-store.js' import {LoadedAppContextOutput} from './app-context.js' import {OrganizationStore} from '../models/organization.js' import {Store} from '../utilities/developer-platform-client.js' @@ -54,9 +54,8 @@ export async function storeContext({ if (storeFqdnToUse) { selectedStore = await fetchStore(organization, storeFqdnToUse, developerPlatformClient, storeTypes) - // never automatically convert a store provided via the command line if (isDevStoresOnly) { - await convertToTransferDisabledStoreIfNeeded(selectedStore, organization.id, developerPlatformClient, 'never') + ensureTransferDisabledStore(selectedStore) } } else { // If no storeFqdn is provided, fetch all stores for the organization and let the user select one. diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index b058f1b407f..b8c14a5822c 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -12,10 +12,6 @@ import {AllAppExtensionRegistrationsQuerySchema} from '../api/graphql/all_app_ex import {AppDeploySchema, AppDeployVariables} from '../api/graphql/app_deploy.js' import {ExtensionCreateSchema, ExtensionCreateVariables} from '../api/graphql/extension_create.js' -import { - ConvertDevToTransferDisabledSchema, - ConvertDevToTransferDisabledStoreVariables, -} from '../api/graphql/convert_dev_to_transfer_disabled_store.js' import {AppVersionsQuerySchema} from '../api/graphql/get_versions_list.js' import { DevelopmentStorePreviewUpdateInput, @@ -276,9 +272,6 @@ export interface DeveloperPlatformClient { updateExtension: (input: ExtensionUpdateDraftMutationVariables) => Promise deploy: (input: AppDeployOptions) => Promise release: (input: {app: MinimalOrganizationApp; version: AppVersionIdentifiers}) => Promise - convertToTransferDisabledStore: ( - input: ConvertDevToTransferDisabledStoreVariables, - ) => Promise updateDeveloperPreview: (input: DevelopmentStorePreviewUpdateInput) => Promise appPreviewMode: (input: FindAppPreviewModeVariables) => Promise sendSampleWebhook: (input: SendSampleWebhookVariables, organizationId: string) => Promise diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index 3bb3ee7528c..f6ce7516fb3 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -45,10 +45,6 @@ import { import {AppDeploySchema} from '../../api/graphql/app_deploy.js' import {AppVersionsQuerySchema as AppVersionsQuerySchemaInterface} from '../../api/graphql/get_versions_list.js' import {ExtensionCreateSchema, ExtensionCreateVariables} from '../../api/graphql/extension_create.js' -import { - ConvertDevToTransferDisabledSchema, - ConvertDevToTransferDisabledStoreVariables, -} from '../../api/graphql/convert_dev_to_transfer_disabled_store.js' import {FindAppPreviewModeSchema, FindAppPreviewModeVariables} from '../../api/graphql/find_app_preview_mode.js' import { DevelopmentStorePreviewUpdateInput, @@ -911,12 +907,6 @@ export class AppManagementClient implements DeveloperPlatformClient { throw new BugError('Not implemented: createExtension') } - async convertToTransferDisabledStore( - _input: ConvertDevToTransferDisabledStoreVariables, - ): Promise { - throw new BugError('Not implemented: convertToTransferDisabledStore') - } - async updateDeveloperPreview( _input: DevelopmentStorePreviewUpdateInput, ): Promise { diff --git a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts index b80967269a5..72af2be4576 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts @@ -44,11 +44,6 @@ import { ExtensionCreateSchema, ExtensionCreateVariables, } from '../../api/graphql/extension_create.js' -import { - ConvertDevToTransferDisabledStoreQuery, - ConvertDevToTransferDisabledSchema, - ConvertDevToTransferDisabledStoreVariables, -} from '../../api/graphql/convert_dev_to_transfer_disabled_store.js' import { FindStoreByDomainQuery, FindStoreByDomainQueryVariables, @@ -502,12 +497,6 @@ export class PartnersClient implements DeveloperPlatformClient { } } - async convertToTransferDisabledStore( - input: ConvertDevToTransferDisabledStoreVariables, - ): Promise { - return this.request(ConvertDevToTransferDisabledStoreQuery, input) - } - async storeByDomain(orgId: string, shopDomain: string, _storeTypes: Store[]): Promise { // Note: storeTypes filtering not implemented for PartnersClient const variables: FindStoreByDomainQueryVariables = {orgId, shopDomain} diff --git a/packages/app/src/cli/utilities/mkcert.test.ts b/packages/app/src/cli/utilities/mkcert.test.ts index 02b396cb396..00a3db01d3b 100644 --- a/packages/app/src/cli/utilities/mkcert.test.ts +++ b/packages/app/src/cli/utilities/mkcert.test.ts @@ -216,6 +216,41 @@ describe('mkcert', () => { expect(downloadGitHubRelease).not.toHaveBeenCalled() }) + testWithTempDir('generates a certificate without prompting when install is true', async ({tempDir}) => { + const appDirectory = tempDir + vi.mocked(generateCertificatePrompt).mockClear() + vi.mocked(exec).mockClear() + + vi.mocked(exec).mockImplementation(async () => { + await mkdir(joinPath(appDirectory, '.shopify')) + await writeFile(joinPath(appDirectory, '.shopify', 'localhost-key.pem'), 'key') + await writeFile(joinPath(appDirectory, '.shopify', 'localhost.pem'), 'cert') + }) + + await generateCertificate({ + appDirectory, + install: true, + platform: 'linux', + }) + + expect(generateCertificatePrompt).not.toHaveBeenCalled() + expect(exec).toHaveBeenCalled() + }) + + testWithTempDir('fails without prompting when install is false and certificates are missing', async ({tempDir}) => { + vi.mocked(generateCertificatePrompt).mockClear() + vi.mocked(exec).mockClear() + const generatePromise = generateCertificate({ + appDirectory: tempDir, + install: false, + platform: 'linux', + }) + + await expect(generatePromise).rejects.toThrow(AbortError) + expect(generateCertificatePrompt).not.toHaveBeenCalled() + expect(exec).not.toHaveBeenCalled() + }) + testWithTempDir('skips certificate generation if the certificate already exists', async ({tempDir}) => { const appDirectory = tempDir await mkdir(joinPath(appDirectory, '.shopify')) diff --git a/packages/app/src/cli/utilities/mkcert.ts b/packages/app/src/cli/utilities/mkcert.ts index b5f97f70dcd..ed181bc3f72 100644 --- a/packages/app/src/cli/utilities/mkcert.ts +++ b/packages/app/src/cli/utilities/mkcert.ts @@ -121,6 +121,7 @@ async function downloadMkcertLicense(dotShopifyPath: string): Promise { const currentDate = new Date(Date.UTC(2022, 1, 1, 10, 0, 0)) let publishEventMock: MockedFunction @@ -202,7 +210,7 @@ describe('event tracking', () => { await inProjectWithFile('package.json', async (args) => { // Given const commandContent = {command: 'dev', topic: 'app'} - const argsWithPassword = args.concat(['--password', 'shptka_abc123']) + const argsWithPassword = args.concat(['--password', 'shptka_abc123', '--store-password', 'store-secret']) await startAnalytics({commandContent, args: argsWithPassword, currentTime: currentDate.getTime() - 100}) // When @@ -214,7 +222,7 @@ describe('event tracking', () => { // Then const expectedPayloadSensitive = { - args: expect.stringMatching(/.*password \*\*\*\*\*/), + args: expect.stringMatching(/.*password \*\*\*\*\*.*store-password \*\*\*\*\*/), metadata: expect.anything(), } expect(publishEventMock).toHaveBeenCalledOnce() @@ -222,36 +230,69 @@ describe('event tracking', () => { }) }) - test('sends SHOPIFY_ environment variables in sensitive payload', async () => { - const originalEnv = {...process.env} - process.env.SHOPIFY_TEST_VAR = 'test_value' - process.env.SHOPIFY_ANOTHER_VAR = 'another_value' - process.env.NOT_SHOPIFY_VAR = 'should_not_appear' - + test('does not send store password environment flags to Monorail', async () => { await inProjectWithFile('package.json', async (args) => { const commandContent = {command: 'dev', topic: 'app'} await startAnalytics({commandContent, args, currentTime: currentDate.getTime() - 100}) + await addSensitiveMetadata(() => ({ + environmentFlags: JSON.stringify({'store-password': 'store-secret'}), + })) - // When const config = { runHook: vi.fn().mockResolvedValue({successes: [], failures: []}), plugins: [], } as any await reportAnalyticsEvent({config, exitMode: 'ok'}) - // Then - const sensitivePayload = publishEventMock.mock.calls[0]![2] expect(publishEventMock).toHaveBeenCalledOnce() - expect(sensitivePayload).toHaveProperty('env_shopify_variables') - expect(sensitivePayload.env_shopify_variables).toBeDefined() - - const shopifyVars = JSON.parse(sensitivePayload.env_shopify_variables as string) - expect(shopifyVars).toHaveProperty('SHOPIFY_TEST_VAR', 'test_value') - expect(shopifyVars).toHaveProperty('SHOPIFY_ANOTHER_VAR', 'another_value') - expect(shopifyVars).not.toHaveProperty('NOT_SHOPIFY_VAR') + expect(publishEventMock.mock.calls[0]![2]).toMatchObject({ + cmd_all_environment_flags: JSON.stringify({'store-password': '*****'}), + }) }) }) + test('sends SHOPIFY_ environment variables in sensitive payload', async () => { + const originalShopifyTestVar = process.env.SHOPIFY_TEST_VAR + const originalShopifyAnotherVar = process.env.SHOPIFY_ANOTHER_VAR + const originalShopifyFlagStorePassword = process.env.SHOPIFY_FLAG_STORE_PASSWORD + const originalNotShopifyVar = process.env.NOT_SHOPIFY_VAR + process.env.SHOPIFY_TEST_VAR = 'test_value' + process.env.SHOPIFY_ANOTHER_VAR = 'another_value' + process.env.SHOPIFY_FLAG_STORE_PASSWORD = 'store-secret' + process.env.NOT_SHOPIFY_VAR = 'should_not_appear' + + try { + await inProjectWithFile('package.json', async (args) => { + const commandContent = {command: 'dev', topic: 'app'} + await startAnalytics({commandContent, args, currentTime: currentDate.getTime() - 100}) + + // When + const config = { + runHook: vi.fn().mockResolvedValue({successes: [], failures: []}), + plugins: [], + } as any + await reportAnalyticsEvent({config, exitMode: 'ok'}) + + // Then + const sensitivePayload = publishEventMock.mock.calls[0]![2] + expect(publishEventMock).toHaveBeenCalledOnce() + expect(sensitivePayload).toHaveProperty('env_shopify_variables') + expect(sensitivePayload.env_shopify_variables).toBeDefined() + + const shopifyVars = JSON.parse(sensitivePayload.env_shopify_variables as string) + expect(shopifyVars).toHaveProperty('SHOPIFY_TEST_VAR', 'test_value') + expect(shopifyVars).toHaveProperty('SHOPIFY_ANOTHER_VAR', 'another_value') + expect(shopifyVars).toHaveProperty('SHOPIFY_FLAG_STORE_PASSWORD', '*****') + expect(shopifyVars).not.toHaveProperty('NOT_SHOPIFY_VAR') + }) + } finally { + restoreEnvVariable('SHOPIFY_TEST_VAR', originalShopifyTestVar) + restoreEnvVariable('SHOPIFY_ANOTHER_VAR', originalShopifyAnotherVar) + restoreEnvVariable('SHOPIFY_FLAG_STORE_PASSWORD', originalShopifyFlagStorePassword) + restoreEnvVariable('NOT_SHOPIFY_VAR', originalNotShopifyVar) + } + }) + test('does nothing when analytics are disabled', async () => { await inProjectWithFile('package.json', async (args) => { // Given diff --git a/packages/cli-kit/src/public/node/analytics.ts b/packages/cli-kit/src/public/node/analytics.ts index 3ee7f2c466d..dcaef13d51c 100644 --- a/packages/cli-kit/src/public/node/analytics.ts +++ b/packages/cli-kit/src/public/node/analytics.ts @@ -198,7 +198,10 @@ async function buildPayload({config, errorMessage, exitMode}: ReportAnalyticsEve function sanitizePayload(payload: T): T { const payloadString = JSON.stringify(payload) // Remove Theme Access passwords from the payload - const sanitizedPayloadString = payloadString.replace(/shptka_\w*/g, '*****') + const sanitizedPayloadString = payloadString + .replace(/shptka_\w*/g, '*****') + .replace(/(--store-password(?:=|\s+))(?:"[^"]*"|'[^']*'|[^\s"]+)/g, '$1*****') + .replace(/((?:store-password|SHOPIFY_FLAG_STORE_PASSWORD)\\?":\\?")[^"\\]*/g, '$1*****') return JSON.parse(sanitizedPayloadString) } diff --git a/packages/cli/README.md b/packages/cli/README.md index dee579b7e24..03c667c5486 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -400,10 +400,10 @@ Run the app. ``` USAGE - $ shopify app dev [--checkout-cart-url ] [--client-id | -c ] [--localhost-port - ] [--no-color] [--no-update] [--notify ] [--path ] [--reset | ] - [--skip-dependencies-installation] [-s ] [--subscription-product-url ] [-t ] - [--theme-app-extension-port ] [--use-localhost | [--tunnel-url | ]] [--verbose] + $ shopify app dev [--checkout-cart-url ] [--client-id | -c ] [--install-mkcert] + [--localhost-port ] [--no-color] [--no-update] [--notify ] [--path ] [--reset | ] + [--skip-dependencies-installation] [-s ] [--store-password ] [--subscription-product-url ] [-t + ] [--theme-app-extension-port ] [--use-localhost | [--tunnel-url | ]] [--verbose] FLAGS -c, --config= [env: SHOPIFY_FLAG_APP_CONFIG] The name of the app configuration. @@ -414,6 +414,8 @@ FLAGS --checkout-cart-url= [env: SHOPIFY_FLAG_CHECKOUT_CART_URL] Resource URL for checkout UI extension. Format: "/cart/{productVariantID}:{productQuantity}" --client-id= [env: SHOPIFY_FLAG_CLIENT_ID] The Client ID of your app. + --install-mkcert [env: SHOPIFY_FLAG_INSTALL_MKCERT] Install and use mkcert to generate + localhost certificates when --use-localhost is enabled without prompting. --localhost-port= [env: SHOPIFY_FLAG_LOCALHOST_PORT] Port to use for localhost. Must be between 1 and 65535. --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. @@ -426,6 +428,8 @@ FLAGS --reset [env: SHOPIFY_FLAG_RESET] Reset all your settings. --skip-dependencies-installation [env: SHOPIFY_FLAG_SKIP_DEPENDENCIES_INSTALLATION] Skips the installation of dependencies. Deprecated, use workspaces instead. + --store-password= [env: SHOPIFY_FLAG_STORE_PASSWORD] The password for storefronts with password + protection. --subscription-product-url= [env: SHOPIFY_FLAG_SUBSCRIPTION_PRODUCT_URL] Resource URL for subscription UI extension. Format: "/products/{productId}" --theme-app-extension-port= [env: SHOPIFY_FLAG_THEME_APP_EXTENSION_PORT] Local port of the theme app diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 4e1a63ca406..59d4fcc7e31 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -977,6 +977,13 @@ "name": "graphiql-port", "type": "option" }, + "install-mkcert": { + "allowNo": false, + "description": "Install and use mkcert to generate localhost certificates when --use-localhost is enabled without prompting.", + "env": "SHOPIFY_FLAG_INSTALL_MKCERT", + "name": "install-mkcert", + "type": "boolean" + }, "localhost-port": { "description": "Port to use for localhost. Must be between 1 and 65535.", "env": "SHOPIFY_FLAG_LOCALHOST_PORT", @@ -1044,6 +1051,14 @@ "name": "store", "type": "option" }, + "store-password": { + "description": "The password for storefronts with password protection.", + "env": "SHOPIFY_FLAG_STORE_PASSWORD", + "hasDynamicHelp": false, + "multiple": false, + "name": "store-password", + "type": "option" + }, "subscription-product-url": { "description": "Resource URL for subscription UI extension. Format: \"/products/{productId}\"", "env": "SHOPIFY_FLAG_SUBSCRIPTION_PRODUCT_URL",