From ea162428d33418dd4b74d724e31b1f4a60c850d0 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Mon, 25 May 2026 14:23:15 +0200 Subject: [PATCH 1/5] Add non-interactive app dev prompt bypasses --- packages/app/src/cli/commands/app/dev.ts | 18 +++++ .../src/cli/services/app/config/link.test.ts | 19 +++++ .../app/src/cli/services/app/config/link.ts | 2 +- packages/app/src/cli/services/dev.ts | 5 ++ .../dev/processes/setup-dev-processes.ts | 1 + .../dev/processes/theme-app-extension.test.ts | 18 +++++ .../dev/processes/theme-app-extension.ts | 3 +- .../src/cli/services/dev/select-store.test.ts | 20 +++++ .../app/src/cli/services/dev/select-store.ts | 13 +++- .../src/cli/services/store-context.test.ts | 47 +++++++++++ .../app/src/cli/services/store-context.ts | 29 ++++++- packages/app/src/cli/utilities/mkcert.test.ts | 35 +++++++++ packages/app/src/cli/utilities/mkcert.ts | 4 +- .../cli-kit/src/public/node/analytics.test.ts | 77 ++++++++++++++----- packages/cli-kit/src/public/node/analytics.ts | 5 +- packages/cli/oclif.manifest.json | 22 ++++++ 16 files changed, 289 insertions(+), 29 deletions(-) diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index 17edc962a05..14e28c8e4b8 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,11 +76,20 @@ 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.', env: 'SHOPIFY_FLAG_NOTIFY', }), + 'convert-transfer-disabled-store': Flags.boolean({ + description: 'Convert the selected development store to a transfer-disabled store without prompting.', + env: 'SHOPIFY_FLAG_CONVERT_TRANSFER_DISABLED_STORE', + default: false, + }), 'graphiql-port': portFlag({ hidden: true, description: 'Local port of the GraphiQL development server.', @@ -121,6 +136,7 @@ export default class Dev extends AppLinkedCommand { appContextResult, storeFqdn: flags.store, forceReselectStore: flags.reset, + transferDisabledStoreConversion: flags['convert-transfer-disabled-store'], }) const devOptions: DevOptions = { @@ -134,9 +150,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/services/app/config/link.test.ts b/packages/app/src/cli/services/app/config/link.test.ts index 3088fb07db1..498a8faedc9 100644 --- a/packages/app/src/cli/services/app/config/link.test.ts +++ b/packages/app/src/cli/services/app/config/link.test.ts @@ -1295,6 +1295,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 5ef02ac9dda..e4b61b8909d 100644 --- a/packages/app/src/cli/services/app/config/link.ts +++ b/packages/app/src/cli/services/app/config/link.ts @@ -408,7 +408,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 0dfcb3b09e1..45285d11c62 100644 --- a/packages/app/src/cli/services/dev.ts +++ b/packages/app/src/cli/services/dev.ts @@ -57,9 +57,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) { @@ -141,6 +143,7 @@ async function prepareForDev(commandOptions: DevOptions): Promise { tunnel, tunnelClient, remoteApp.configuration, + commandOptions.installMkcert, ) app.webs = webs @@ -250,6 +253,7 @@ async function setupNetworkingOptions( tunnelOptions: TunnelMode, tunnelClient?: TunnelClient, remoteAppConfig?: AppConfigurationUsedByCli, + installMkcert?: boolean, ) { const {backendConfig, frontendConfig} = frontAndBackendConfig(webs) @@ -287,6 +291,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 f0ec1c92b1c..1277bb675d0 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 @@ -169,6 +169,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 870405d9f4d..e44a63a7e8f 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 @@ -212,6 +212,24 @@ describe('setupPreviewThemeAppExtensionsProcess', () => { expect(result!.options.storefrontPassword).toEqual(storefrontPassword) expect(result!.options.crawlerSignatureHeaders).toEqual(crawlerSignatureHeaders) }) + + 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 c9147f3505b..d4065b78d90 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 @@ -33,6 +33,7 @@ interface HostThemeSetupOptions { storeFqdn: string theme?: string themeExtensionPort?: number + storePassword?: string } export interface PreviewThemeAppExtensionsProcess extends BaseProcess { @@ -64,7 +65,7 @@ export async function setupPreviewThemeAppExtensionsProcess( isStorefrontPasswordProtected(adminSession), ]) const storefrontPassword = isPasswordProtected - ? await ensureValidPassword(undefined, storeFqdn, crawlerSignatureHeaders) + ? await ensureValidPassword(options.storePassword, storeFqdn, crawlerSignatureHeaders) : 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 75f88a12268..ff95391c446 100644 --- a/packages/app/src/cli/services/dev/select-store.test.ts +++ b/packages/app/src/cli/services/dev/select-store.test.ts @@ -116,6 +116,26 @@ describe('selectStore', async () => { expect(confirmConversionToTransferDisabledStorePrompt).toHaveBeenCalled() }) + test('converts store without prompting when conversion mode is always', async () => { + // Given + vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE2) + const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.Partners}) + const convertToTransferDisabledStore = vi.spyOn(developerPlatformClient, 'convertToTransferDisabledStore') + + // When + const got = await selectStore( + {stores: [STORE1, STORE2], hasMorePages: false}, + ORG1, + developerPlatformClient, + 'always', + ) + + // Then + expect(got).toEqual(STORE2) + expect(confirmConversionToTransferDisabledStorePrompt).not.toHaveBeenCalled() + expect(convertToTransferDisabledStore).toHaveBeenCalled() + }) + test('choosing not to convert to transfer-disabled forces another prompt', async () => { // Given vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE2) diff --git a/packages/app/src/cli/services/dev/select-store.ts b/packages/app/src/cli/services/dev/select-store.ts index 000ef277971..36c37ba6add 100644 --- a/packages/app/src/cli/services/dev/select-store.ts +++ b/packages/app/src/cli/services/dev/select-store.ts @@ -14,6 +14,8 @@ import {renderInfo, renderTasks} from '@shopify/cli-kit/node/ui' import {AbortError, BugError, CancelExecution} from '@shopify/cli-kit/node/error' import {outputSuccess} from '@shopify/cli-kit/node/output' +export type TransferDisabledStoreConversionMode = 'prompt-first' | 'always' | 'never' + /** * Select store from list or * If a cachedStoreName is provided, we check if it is valid and return it. If it's not valid, ignore it. @@ -28,6 +30,7 @@ export async function selectStore( storesSearch: Paginateable<{stores: OrganizationStore[]}>, org: Organization, developerPlatformClient: DeveloperPlatformClient, + conversionMode: TransferDisabledStoreConversionMode = 'prompt-first', ): Promise { const showDomainOnPrompt = developerPlatformClient.clientName === ClientName.AppManagement const onSearchForStoresByName = async (term: string) => developerPlatformClient.devStoresForOrg(org.id, term) @@ -57,7 +60,7 @@ export async function selectStore( store, org.id, developerPlatformClient, - 'prompt-first', + conversionMode, ) while (!storeIsValid) { // eslint-disable-next-line no-await-in-loop @@ -66,7 +69,7 @@ export async function selectStore( throw new CancelExecution() } // eslint-disable-next-line no-await-in-loop - storeIsValid = await convertToTransferDisabledStoreIfNeeded(store, org.id, developerPlatformClient, 'prompt-first') + storeIsValid = await convertToTransferDisabledStoreIfNeeded(store, org.id, developerPlatformClient, conversionMode) } return store @@ -124,7 +127,7 @@ export async function convertToTransferDisabledStoreIfNeeded( store: OrganizationStore, orgId: string, developerPlatformClient: DeveloperPlatformClient, - conversionMode: 'prompt-first' | 'never', + conversionMode: TransferDisabledStoreConversionMode, ): Promise { if (store.transferDisabled) return true @@ -145,6 +148,10 @@ export async function convertToTransferDisabledStoreIfNeeded( await convertStoreToTransferDisabled(store, orgId, developerPlatformClient) return true } + case 'always': { + await convertStoreToTransferDisabled(store, orgId, developerPlatformClient) + return true + } case 'never': { throw new AbortError( 'The store you specified is not transfer-disabled', diff --git a/packages/app/src/cli/services/store-context.test.ts b/packages/app/src/cli/services/store-context.test.ts index bb83f8538da..ea617238a09 100644 --- a/packages/app/src/cli/services/store-context.test.ts +++ b/packages/app/src/cli/services/store-context.test.ts @@ -116,6 +116,7 @@ describe('storeContext', () => { {stores: allStores, hasMorePages: false}, mockOrganization, mockDeveloperPlatformClient, + 'prompt-first', ) expect(result).toEqual(mockStore) }) @@ -138,11 +139,57 @@ describe('storeContext', () => { {stores: allStores, hasMorePages: false}, mockOrganization, mockDeveloperPlatformClient, + 'prompt-first', ) expect(result).toEqual(mockStore) }) }) + test('converts an explicitly provided store when conversion is requested', async () => { + await inTemporaryDirectory(async (dir) => { + vi.mocked(fetchStore).mockResolvedValue(mockStore) + await prepareAppFolder(mockApp, dir) + + await storeContext({ + appContextResult, + storeFqdn: 'explicit-store.myshopify.com', + forceReselectStore: false, + transferDisabledStoreConversion: true, + }) + + expect(convertToTransferDisabledStoreIfNeeded).toHaveBeenCalledWith( + mockStore, + mockOrganization.id, + mockDeveloperPlatformClient, + 'always', + ) + }) + }) + + test('passes conversion preference when selecting a store', async () => { + await inTemporaryDirectory(async (dir) => { + const appWithoutCachedStore = testAppLinked() + await prepareAppFolder(appWithoutCachedStore, dir) + const allStores = [mockStore] + + vi.mocked(mockDeveloperPlatformClient.devStoresForOrg).mockResolvedValue({stores: allStores, hasMorePages: false}) + vi.mocked(selectStore).mockResolvedValue(mockStore) + + await storeContext({ + appContextResult: {...appContextResult, app: appWithoutCachedStore}, + forceReselectStore: false, + transferDisabledStoreConversion: true, + }) + + expect(selectStore).toHaveBeenCalledWith( + {stores: allStores, hasMorePages: false}, + mockOrganization, + mockDeveloperPlatformClient, + 'always', + ) + }) + }) + test('throws an error when fetchStore fails', async () => { vi.mocked(fetchStore).mockRejectedValue(new Error('Store not found')) diff --git a/packages/app/src/cli/services/store-context.ts b/packages/app/src/cli/services/store-context.ts index 42827d58bb1..4a9c0993caf 100644 --- a/packages/app/src/cli/services/store-context.ts +++ b/packages/app/src/cli/services/store-context.ts @@ -1,5 +1,9 @@ import {fetchStore} from './dev/fetch.js' -import {convertToTransferDisabledStoreIfNeeded, selectStore} from './dev/select-store.js' +import { + convertToTransferDisabledStoreIfNeeded, + selectStore, + type TransferDisabledStoreConversionMode, +} 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' @@ -20,6 +24,7 @@ interface StoreContextOptions { forceReselectStore: boolean storeFqdn?: string storeTypes?: Store[] + transferDisabledStoreConversion?: boolean } /** @@ -34,6 +39,7 @@ export async function storeContext({ storeFqdn, forceReselectStore, storeTypes = ['APP_DEVELOPMENT'], + transferDisabledStoreConversion, }: StoreContextOptions): Promise { const {app, organization, developerPlatformClient} = appContextResult let selectedStore: OrganizationStore @@ -51,17 +57,23 @@ export async function storeContext({ // Check if we're filtering to dev stores only const isDevStoresOnly = storeTypes.length === 1 && storeTypes[0] === 'APP_DEVELOPMENT' + const conversionMode = transferDisabledStoreConversionMode(transferDisabledStoreConversion, Boolean(storeFqdnToUse)) if (storeFqdnToUse) { selectedStore = await fetchStore(organization, storeFqdnToUse, developerPlatformClient, storeTypes) - // never automatically convert a store provided via the command line + // Explicit stores keep the previous fail-fast behavior unless conversion is also explicit. if (isDevStoresOnly) { - await convertToTransferDisabledStoreIfNeeded(selectedStore, organization.id, developerPlatformClient, 'never') + await convertToTransferDisabledStoreIfNeeded( + selectedStore, + organization.id, + developerPlatformClient, + conversionMode, + ) } } else { // If no storeFqdn is provided, fetch all stores for the organization and let the user select one. const allStores = await developerPlatformClient.devStoresForOrg(organization.id) - selectedStore = await selectStore(allStores, organization, developerPlatformClient) + selectedStore = await selectStore(allStores, organization, developerPlatformClient, conversionMode) } selectedStore.shopDomain = normalizeStoreFqdn(selectedStore.shopDomain) @@ -78,6 +90,15 @@ export async function storeContext({ return selectedStore } +function transferDisabledStoreConversionMode( + transferDisabledStoreConversion: boolean | undefined, + storeFqdnToUse: boolean, +): TransferDisabledStoreConversionMode { + if (transferDisabledStoreConversion === true) return 'always' + if (transferDisabledStoreConversion === false || storeFqdnToUse) return 'never' + return 'prompt-first' +} + async function logMetadata(selectedStore: OrganizationStore, resetUsed: boolean) { await metadata.addPublicMetadata(() => ({ cmd_app_reset_used: resetUsed, 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/oclif.manifest.json b/packages/cli/oclif.manifest.json index 98fcb227fe2..faf633bfdee 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1039,6 +1039,13 @@ "name": "config", "type": "option" }, + "convert-transfer-disabled-store": { + "allowNo": false, + "description": "Convert the selected development store to a transfer-disabled store without prompting.", + "env": "SHOPIFY_FLAG_CONVERT_TRANSFER_DISABLED_STORE", + "name": "convert-transfer-disabled-store", + "type": "boolean" + }, "graphiql-key": { "description": "Key used to authenticate GraphiQL requests. By default, a key is automatically derived from the app secret. Use this flag to override with a custom key.", "env": "SHOPIFY_FLAG_GRAPHIQL_KEY", @@ -1057,6 +1064,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", @@ -1124,6 +1138,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", From 9cdbb8f17e5d4ba932720c221440473533bd425c Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 2 Jul 2026 12:55:42 +0200 Subject: [PATCH 2/5] Remove transfer-disabled store conversion prompt --- .../convert_dev_to_transfer_disabled_store.ts | 30 ----- packages/app/src/cli/commands/app/dev.ts | 6 - .../app/src/cli/models/app/app.test-data.ts | 10 -- packages/app/src/cli/prompts/dev.ts | 9 -- .../src/cli/services/dev/select-store.test.ts | 58 +-------- .../app/src/cli/services/dev/select-store.ts | 110 +++--------------- .../src/cli/services/store-context.test.ts | 56 +-------- .../app/src/cli/services/store-context.ts | 28 +---- .../utilities/developer-platform-client.ts | 7 -- .../app-management-client.ts | 10 -- .../partners-client.ts | 11 -- packages/cli/oclif.manifest.json | 7 -- 12 files changed, 23 insertions(+), 319 deletions(-) delete mode 100644 packages/app/src/cli/api/graphql/convert_dev_to_transfer_disabled_store.ts 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 14e28c8e4b8..3bffab4d07d 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -85,11 +85,6 @@ export default class Dev extends AppLinkedCommand { '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.', env: 'SHOPIFY_FLAG_NOTIFY', }), - 'convert-transfer-disabled-store': Flags.boolean({ - description: 'Convert the selected development store to a transfer-disabled store without prompting.', - env: 'SHOPIFY_FLAG_CONVERT_TRANSFER_DISABLED_STORE', - default: false, - }), 'graphiql-port': portFlag({ hidden: true, description: 'Local port of the GraphiQL development server.', @@ -136,7 +131,6 @@ export default class Dev extends AppLinkedCommand { appContextResult, storeFqdn: flags.store, forceReselectStore: flags.reset, - transferDisabledStoreConversion: flags['convert-transfer-disabled-store'], }) const devOptions: DevOptions = { 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 b60f0b612c4..53d2e66f0fd 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -41,7 +41,6 @@ import { } from '../../utilities/developer-platform-client.js' import {AllAppExtensionRegistrationsQuerySchema} from '../../api/graphql/all_app_extension_registrations.js' import {AppDeploySchema, AppDeployVariables} from '../../api/graphql/app_deploy.js' -import {ConvertDevToTransferDisabledStoreVariables} from '../../api/graphql/convert_dev_to_transfer_disabled_store.js' import {SendSampleWebhookSchema, SendSampleWebhookVariables} from '../../services/webhook/request-sample.js' import {PublicApiVersionsSchema} from '../../services/webhook/request-api-versions.js' import {WebhookTopicsSchema, WebhookTopicsVariables} from '../../services/webhook/request-topics.js' @@ -1241,13 +1240,6 @@ const generateSignedUploadUrlResponse: AssetUrlSchema = { userErrors: [], } -const convertedToTransferDisabledStoreResponse = { - convertDevToTestStore: { - convertedToTestStore: true, - userErrors: [], - }, -} - const organizationsResponse: Organization[] = [testOrganization()] const sendSampleWebhookResponse: SendSampleWebhookSchema = { @@ -1339,8 +1331,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), sendSampleWebhook: (_input: SendSampleWebhookVariables) => Promise.resolve(sendSampleWebhookResponse), apiVersions: () => Promise.resolve(apiVersionsResponse), topics: (_input: WebhookTopicsVariables) => Promise.resolve(topicsResponse), diff --git a/packages/app/src/cli/prompts/dev.ts b/packages/app/src/cli/prompts/dev.ts index e8c249fda76..651b5ff7c24 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/dev/select-store.test.ts b/packages/app/src/cli/services/dev/select-store.test.ts index ff95391c446..68a1ad446da 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' @@ -93,71 +89,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('converts store without prompting when conversion mode is always', async () => { - // Given - vi.mocked(selectStorePrompt).mockResolvedValueOnce(STORE2) - const developerPlatformClient = testDeveloperPlatformClient({clientName: ClientName.Partners}) - const convertToTransferDisabledStore = vi.spyOn(developerPlatformClient, 'convertToTransferDisabledStore') - - // When - const got = await selectStore( - {stores: [STORE1, STORE2], hasMorePages: false}, - ORG1, - developerPlatformClient, - 'always', - ) - - // Then - expect(got).toEqual(STORE2) - expect(confirmConversionToTransferDisabledStorePrompt).not.toHaveBeenCalled() - expect(convertToTransferDisabledStore).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 36c37ba6add..f201d696d53 100644 --- a/packages/app/src/cli/services/dev/select-store.ts +++ b/packages/app/src/cli/services/dev/select-store.ts @@ -1,20 +1,9 @@ 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 {AbortError, BugError, CancelExecution} from '@shopify/cli-kit/node/error' -import {outputSuccess} from '@shopify/cli-kit/node/output' - -export type TransferDisabledStoreConversionMode = 'prompt-first' | 'always' | 'never' +import {AbortError, CancelExecution} from '@shopify/cli-kit/node/error' /** * Select store from list or @@ -30,12 +19,11 @@ export async function selectStore( storesSearch: Paginateable<{stores: OrganizationStore[]}>, org: Organization, developerPlatformClient: DeveloperPlatformClient, - conversionMode: TransferDisabledStoreConversionMode = 'prompt-first', ): Promise { const showDomainOnPrompt = developerPlatformClient.clientName === ClientName.AppManagement const 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, @@ -56,21 +44,7 @@ export async function selectStore( store = await selectStore({stores, hasMorePages: false}, org, developerPlatformClient) } - let storeIsValid = await convertToTransferDisabledStoreIfNeeded( - store, - org.id, - developerPlatformClient, - conversionMode, - ) - 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, conversionMode) - } + ensureTransferDisabledStore(store) return store } @@ -113,23 +87,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: TransferDisabledStoreConversionMode, -): Promise { - if (store.transferDisabled) return true +export function ensureTransferDisabledStore(store: OrganizationStore): void { + if (store.transferDisabled) return if (!store.transferDisabled && !store.convertableToPartnerTest) { throw new AbortError( @@ -138,54 +102,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 'always': { - 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 ea617238a09..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) }) }) @@ -116,7 +111,6 @@ describe('storeContext', () => { {stores: allStores, hasMorePages: false}, mockOrganization, mockDeveloperPlatformClient, - 'prompt-first', ) expect(result).toEqual(mockStore) }) @@ -139,57 +133,11 @@ describe('storeContext', () => { {stores: allStores, hasMorePages: false}, mockOrganization, mockDeveloperPlatformClient, - 'prompt-first', ) expect(result).toEqual(mockStore) }) }) - test('converts an explicitly provided store when conversion is requested', async () => { - await inTemporaryDirectory(async (dir) => { - vi.mocked(fetchStore).mockResolvedValue(mockStore) - await prepareAppFolder(mockApp, dir) - - await storeContext({ - appContextResult, - storeFqdn: 'explicit-store.myshopify.com', - forceReselectStore: false, - transferDisabledStoreConversion: true, - }) - - expect(convertToTransferDisabledStoreIfNeeded).toHaveBeenCalledWith( - mockStore, - mockOrganization.id, - mockDeveloperPlatformClient, - 'always', - ) - }) - }) - - test('passes conversion preference when selecting a store', async () => { - await inTemporaryDirectory(async (dir) => { - const appWithoutCachedStore = testAppLinked() - await prepareAppFolder(appWithoutCachedStore, dir) - const allStores = [mockStore] - - vi.mocked(mockDeveloperPlatformClient.devStoresForOrg).mockResolvedValue({stores: allStores, hasMorePages: false}) - vi.mocked(selectStore).mockResolvedValue(mockStore) - - await storeContext({ - appContextResult: {...appContextResult, app: appWithoutCachedStore}, - forceReselectStore: false, - transferDisabledStoreConversion: true, - }) - - expect(selectStore).toHaveBeenCalledWith( - {stores: allStores, hasMorePages: false}, - mockOrganization, - mockDeveloperPlatformClient, - 'always', - ) - }) - }) - test('throws an error when fetchStore fails', async () => { vi.mocked(fetchStore).mockRejectedValue(new Error('Store not found')) diff --git a/packages/app/src/cli/services/store-context.ts b/packages/app/src/cli/services/store-context.ts index 4a9c0993caf..05f9f9b3673 100644 --- a/packages/app/src/cli/services/store-context.ts +++ b/packages/app/src/cli/services/store-context.ts @@ -1,9 +1,5 @@ import {fetchStore} from './dev/fetch.js' -import { - convertToTransferDisabledStoreIfNeeded, - selectStore, - type TransferDisabledStoreConversionMode, -} 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' @@ -24,7 +20,6 @@ interface StoreContextOptions { forceReselectStore: boolean storeFqdn?: string storeTypes?: Store[] - transferDisabledStoreConversion?: boolean } /** @@ -39,7 +34,6 @@ export async function storeContext({ storeFqdn, forceReselectStore, storeTypes = ['APP_DEVELOPMENT'], - transferDisabledStoreConversion, }: StoreContextOptions): Promise { const {app, organization, developerPlatformClient} = appContextResult let selectedStore: OrganizationStore @@ -57,23 +51,16 @@ export async function storeContext({ // Check if we're filtering to dev stores only const isDevStoresOnly = storeTypes.length === 1 && storeTypes[0] === 'APP_DEVELOPMENT' - const conversionMode = transferDisabledStoreConversionMode(transferDisabledStoreConversion, Boolean(storeFqdnToUse)) if (storeFqdnToUse) { selectedStore = await fetchStore(organization, storeFqdnToUse, developerPlatformClient, storeTypes) - // Explicit stores keep the previous fail-fast behavior unless conversion is also explicit. if (isDevStoresOnly) { - await convertToTransferDisabledStoreIfNeeded( - selectedStore, - organization.id, - developerPlatformClient, - conversionMode, - ) + ensureTransferDisabledStore(selectedStore) } } else { // If no storeFqdn is provided, fetch all stores for the organization and let the user select one. const allStores = await developerPlatformClient.devStoresForOrg(organization.id) - selectedStore = await selectStore(allStores, organization, developerPlatformClient, conversionMode) + selectedStore = await selectStore(allStores, organization, developerPlatformClient) } selectedStore.shopDomain = normalizeStoreFqdn(selectedStore.shopDomain) @@ -90,15 +77,6 @@ export async function storeContext({ return selectedStore } -function transferDisabledStoreConversionMode( - transferDisabledStoreConversion: boolean | undefined, - storeFqdnToUse: boolean, -): TransferDisabledStoreConversionMode { - if (transferDisabledStoreConversion === true) return 'always' - if (transferDisabledStoreConversion === false || storeFqdnToUse) return 'never' - return 'prompt-first' -} - async function logMetadata(selectedStore: OrganizationStore, resetUsed: boolean) { await metadata.addPublicMetadata(() => ({ cmd_app_reset_used: resetUsed, diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index f092e6d1d1b..dac97fd382a 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -11,10 +11,6 @@ import { import {AllAppExtensionRegistrationsQuerySchema} from '../api/graphql/all_app_extension_registrations.js' import {AppDeploySchema, AppDeployVariables} from '../api/graphql/app_deploy.js' -import { - ConvertDevToTransferDisabledSchema, - ConvertDevToTransferDisabledStoreVariables, -} from '../api/graphql/convert_dev_to_transfer_disabled_store.js' import {AppVersionsQuerySchema} from '../api/graphql/get_versions_list.js' import {AppReleaseSchema} from '../api/graphql/app_release.js' import {AppVersionsDiffSchema} from '../api/graphql/app_versions_diff.js' @@ -257,9 +253,6 @@ export interface DeveloperPlatformClient { generateSignedUploadUrl: (app: MinimalAppIdentifiers) => Promise deploy: (input: AppDeployOptions) => Promise release: (input: {app: MinimalOrganizationApp; version: AppVersionIdentifiers}) => Promise - convertToTransferDisabledStore: ( - input: ConvertDevToTransferDisabledStoreVariables, - ) => Promise sendSampleWebhook: (input: SendSampleWebhookVariables, organizationId: string) => Promise apiVersions: (organizationId: string) => Promise topics: (input: WebhookTopicsVariables, 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 ff9fae67966..a6e1d30c822 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 @@ -44,10 +44,6 @@ import { } from '../../api/graphql/all_app_extension_registrations.js' import {AppDeploySchema} from '../../api/graphql/app_deploy.js' import {AppVersionsQuerySchema as AppVersionsQuerySchemaInterface} from '../../api/graphql/get_versions_list.js' -import { - ConvertDevToTransferDisabledSchema, - ConvertDevToTransferDisabledStoreVariables, -} from '../../api/graphql/convert_dev_to_transfer_disabled_store.js' import {AppReleaseSchema} from '../../api/graphql/app_release.js' import {AppVersionsDiffSchema} from '../../api/graphql/app_versions_diff.js' import { @@ -897,12 +893,6 @@ export class AppManagementClient implements DeveloperPlatformClient { } } - async convertToTransferDisabledStore( - _input: ConvertDevToTransferDisabledStoreVariables, - ): Promise { - throw new BugError('Not implemented: convertToTransferDisabledStore') - } - async sendSampleWebhook(input: SendSampleWebhookVariables, organizationId: string): Promise { const query = CliTesting const variables = { 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 fa2dafdc36e..8e31833517b 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 @@ -39,11 +39,6 @@ import { GenerateSignedUploadUrlSchema, GenerateSignedUploadUrlVariables, } from '../../api/graphql/generate_signed_upload_url.js' -import { - ConvertDevToTransferDisabledStoreQuery, - ConvertDevToTransferDisabledSchema, - ConvertDevToTransferDisabledStoreVariables, -} from '../../api/graphql/convert_dev_to_transfer_disabled_store.js' import { FindStoreByDomainQuery, FindStoreByDomainQueryVariables, @@ -470,12 +465,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/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index faf633bfdee..71b84a7f434 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1039,13 +1039,6 @@ "name": "config", "type": "option" }, - "convert-transfer-disabled-store": { - "allowNo": false, - "description": "Convert the selected development store to a transfer-disabled store without prompting.", - "env": "SHOPIFY_FLAG_CONVERT_TRANSFER_DISABLED_STORE", - "name": "convert-transfer-disabled-store", - "type": "boolean" - }, "graphiql-key": { "description": "Key used to authenticate GraphiQL requests. By default, a key is automatically derived from the app secret. Use this flag to override with a custom key.", "env": "SHOPIFY_FLAG_GRAPHIQL_KEY", From c64f1904d1c958d994267e87095c707e5927cbd2 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 2 Jul 2026 12:56:36 +0200 Subject: [PATCH 3/5] Add changeset for app dev prompt updates --- .changeset/app-dev-non-interactive-flags.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/app-dev-non-interactive-flags.md diff --git a/.changeset/app-dev-non-interactive-flags.md b/.changeset/app-dev-non-interactive-flags.md new file mode 100644 index 00000000000..8f3a43235c6 --- /dev/null +++ b/.changeset/app-dev-non-interactive-flags.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Add non-interactive `app dev` options and fail fast for transfer-enabled dev stores. From c94ee62b289a64802549c5318e5f6550470e644f Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 2 Jul 2026 13:10:58 +0200 Subject: [PATCH 4/5] Refresh generated dev docs --- .changeset/app-dev-non-interactive-flags.md | 2 +- packages/app/src/cli/commands/app/dev.ts | 1 + packages/app/src/cli/services/dev.ts | 2 +- packages/app/src/cli/utilities/mkcert.test.ts | 8 ++++---- packages/app/src/cli/utilities/mkcert.ts | 6 +++--- packages/cli/oclif.manifest.json | 3 +++ 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.changeset/app-dev-non-interactive-flags.md b/.changeset/app-dev-non-interactive-flags.md index 8f3a43235c6..f59065d305c 100644 --- a/.changeset/app-dev-non-interactive-flags.md +++ b/.changeset/app-dev-non-interactive-flags.md @@ -2,4 +2,4 @@ '@shopify/app': patch --- -Add non-interactive `app dev` options and fail fast for transfer-enabled dev stores. +Add non-interactive `app dev` options: `--store-password` and `--install-mkcert`. diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index 3bffab4d07d..41c45044c78 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -62,6 +62,7 @@ export default class Dev extends AppLinkedCommand { 'Install and use mkcert to generate localhost certificates when --use-localhost is enabled without prompting.', env: 'SHOPIFY_FLAG_INSTALL_MKCERT', default: false, + dependsOn: ['use-localhost'], }), 'localhost-port': portFlag({ description: 'Port to use for localhost.', diff --git a/packages/app/src/cli/services/dev.ts b/packages/app/src/cli/services/dev.ts index 45285d11c62..24e0b135bb0 100644 --- a/packages/app/src/cli/services/dev.ts +++ b/packages/app/src/cli/services/dev.ts @@ -291,7 +291,7 @@ async function setupNetworkingOptions( if (tunnelOptions.mode === 'use-localhost') { const {keyContent, certContent, certPath} = await generateCertificate({ appDirectory, - install: installMkcert, + forceInstall: installMkcert, }) reverseProxyCert = { diff --git a/packages/app/src/cli/utilities/mkcert.test.ts b/packages/app/src/cli/utilities/mkcert.test.ts index 00a3db01d3b..c7ec7127a74 100644 --- a/packages/app/src/cli/utilities/mkcert.test.ts +++ b/packages/app/src/cli/utilities/mkcert.test.ts @@ -216,7 +216,7 @@ describe('mkcert', () => { expect(downloadGitHubRelease).not.toHaveBeenCalled() }) - testWithTempDir('generates a certificate without prompting when install is true', async ({tempDir}) => { + testWithTempDir('generates a certificate without prompting when forceInstall is true', async ({tempDir}) => { const appDirectory = tempDir vi.mocked(generateCertificatePrompt).mockClear() vi.mocked(exec).mockClear() @@ -229,7 +229,7 @@ describe('mkcert', () => { await generateCertificate({ appDirectory, - install: true, + forceInstall: true, platform: 'linux', }) @@ -237,12 +237,12 @@ describe('mkcert', () => { expect(exec).toHaveBeenCalled() }) - testWithTempDir('fails without prompting when install is false and certificates are missing', async ({tempDir}) => { + testWithTempDir('fails without prompting when forceInstall is false and certificates are missing', async ({tempDir}) => { vi.mocked(generateCertificatePrompt).mockClear() vi.mocked(exec).mockClear() const generatePromise = generateCertificate({ appDirectory: tempDir, - install: false, + forceInstall: false, platform: 'linux', }) diff --git a/packages/app/src/cli/utilities/mkcert.ts b/packages/app/src/cli/utilities/mkcert.ts index ed181bc3f72..f0ea73f3bd0 100644 --- a/packages/app/src/cli/utilities/mkcert.ts +++ b/packages/app/src/cli/utilities/mkcert.ts @@ -121,7 +121,7 @@ async function downloadMkcertLicense(dotShopifyPath: string): Promise Date: Thu, 9 Jul 2026 12:00:45 +0200 Subject: [PATCH 5/5] Fix app dev localhost flag dependency --- .../generated/generated_docs_data_v2.json | 20 +++++- packages/app/src/cli/commands/app/dev.test.ts | 72 +++++++++++++++++++ packages/app/src/cli/commands/app/dev.ts | 6 +- .../src/cli/services/app/config/link.test.ts | 19 ----- .../app/src/cli/services/app/config/link.ts | 2 +- .../dev/processes/theme-app-extension.test.ts | 2 +- packages/app/src/cli/utilities/mkcert.test.ts | 29 ++++---- packages/cli/README.md | 15 +++- 8 files changed, 123 insertions(+), 42 deletions(-) create mode 100644 packages/app/src/cli/commands/app/dev.test.ts diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index d2157241446..0ec90ceec51 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -979,6 +979,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", @@ -1042,6 +1051,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", @@ -1115,7 +1133,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 * 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 * 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 * 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/commands/app/dev.test.ts b/packages/app/src/cli/commands/app/dev.test.ts new file mode 100644 index 00000000000..26ac10c63ee --- /dev/null +++ b/packages/app/src/cli/commands/app/dev.test.ts @@ -0,0 +1,72 @@ +import Dev from './dev.js' +import {dev} from '../../services/dev.js' +import {linkedAppContext} from '../../services/app-context.js' +import {storeContext} from '../../services/store-context.js' +import {getTunnelMode} from '../../services/dev/tunnel-mode.js' +import {checkFolderIsValidApp} from '../../models/app/loader.js' +import { + testAppLinked, + testDeveloperPlatformClient, + testOrganization, + testOrganizationApp, + testOrganizationStore, + testProject, +} from '../../models/app/app.test-data.js' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('../../services/dev.js') +vi.mock('../../services/app-context.js') +vi.mock('../../services/store-context.js') +vi.mock('../../services/dev/tunnel-mode.js') +vi.mock('../../models/app/loader.js') +vi.mock('@shopify/cli-kit/node/metadata') + +describe('app dev command', () => { + beforeEach(() => { + vi.mocked(dev).mockReset() + vi.mocked(linkedAppContext).mockReset() + vi.mocked(storeContext).mockReset() + vi.mocked(getTunnelMode).mockReset() + vi.mocked(checkFolderIsValidApp).mockReset() + vi.mocked(addPublicMetadata).mockReset() + }) + + test('does not require --use-localhost when --install-mkcert is not passed', async () => { + await inTemporaryDirectory(async (tmp) => { + const app = testAppLinked({directory: tmp}) + const appContextResult = { + app, + remoteApp: testOrganizationApp(), + organization: testOrganization(), + project: testProject(), + activeConfig: {} as never, + specifications: [], + developerPlatformClient: testDeveloperPlatformClient(), + } as Awaited> + const store = testOrganizationStore({shopDomain: 'dev-store.myshopify.com'}) + + vi.mocked(linkedAppContext).mockResolvedValue(appContextResult) + vi.mocked(storeContext).mockResolvedValue(store) + vi.mocked(getTunnelMode).mockResolvedValue({mode: 'auto'}) + + await Dev.run(['--path', tmp, '--store', store.shopDomain], import.meta.url) + + expect(getTunnelMode).toHaveBeenCalledWith({ + useLocalhost: false, + tunnelUrl: undefined, + localhostPort: undefined, + }) + expect(dev).toHaveBeenCalledWith(expect.objectContaining({installMkcert: false, tunnel: {mode: 'auto'}})) + }) + }) + + test('requires --use-localhost when --install-mkcert is passed', async () => { + await inTemporaryDirectory(async (tmp) => { + await expect(Dev.run(['--path', tmp, '--install-mkcert'], import.meta.url)).rejects.toThrow() + + expect(dev).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index 41c45044c78..ee6f217ee97 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -54,14 +54,12 @@ export default class Dev extends AppLinkedCommand { description: "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)", env: 'SHOPIFY_FLAG_USE_LOCALHOST', - 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, dependsOn: ['use-localhost'], }), 'localhost-port': portFlag({ @@ -107,7 +105,7 @@ export default class Dev extends AppLinkedCommand { const {flags} = await this.parse(Dev) const tunnelMode = await getTunnelMode({ - useLocalhost: flags['use-localhost'], + useLocalhost: flags['use-localhost'] ?? false, tunnelUrl: flags['tunnel-url'], localhostPort: flags['localhost-port'], }) @@ -149,7 +147,7 @@ export default class Dev extends AppLinkedCommand { notify: flags.notify, graphiqlPort: flags['graphiql-port'], graphiqlKey: flags['graphiql-key'], - installMkcert: flags['install-mkcert'], + installMkcert: flags['install-mkcert'] ?? false, tunnel: tunnelMode, } 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 498a8faedc9..3088fb07db1 100644 --- a/packages/app/src/cli/services/app/config/link.test.ts +++ b/packages/app/src/cli/services/app/config/link.test.ts @@ -1295,25 +1295,6 @@ 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 e4b61b8909d..5ef02ac9dda 100644 --- a/packages/app/src/cli/services/app/config/link.ts +++ b/packages/app/src/cli/services/app/config/link.ts @@ -408,7 +408,7 @@ function buildOptionsForGeneratedConfigFile(options: { } = options const buildOptions = { ...(linkedAppWasNewlyCreated ? {include_config_on_deploy: true} : {}), - ...(defaultToUpdateUrlsOnDev ? {automatically_update_urls_on_dev: true} : {}), + ...(defaultToUpdateUrlsOnDev && linkedAppWasNewlyCreated ? {automatically_update_urls_on_dev: true} : {}), ...(linkedAppAndClientIdFromFileAreInSync ? existingBuildOptions : {}), } if (isEmpty(buildOptions)) { 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 e44a63a7e8f..166a1322b0a 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 @@ -227,7 +227,7 @@ describe('setupPreviewThemeAppExtensionsProcess', () => { storePassword: 'provided-password', }) - expect(ensureValidPassword).toHaveBeenCalledWith('provided-password', 'test.myshopify.com') + expect(ensureValidPassword).toHaveBeenCalledWith('provided-password', 'test.myshopify.com', crawlerSignatureHeaders) expect(result!.options.storefrontPassword).toEqual('validated-password') }) }) diff --git a/packages/app/src/cli/utilities/mkcert.test.ts b/packages/app/src/cli/utilities/mkcert.test.ts index c7ec7127a74..36940aa85fa 100644 --- a/packages/app/src/cli/utilities/mkcert.test.ts +++ b/packages/app/src/cli/utilities/mkcert.test.ts @@ -237,19 +237,22 @@ describe('mkcert', () => { expect(exec).toHaveBeenCalled() }) - testWithTempDir('fails without prompting when forceInstall is false and certificates are missing', async ({tempDir}) => { - vi.mocked(generateCertificatePrompt).mockClear() - vi.mocked(exec).mockClear() - const generatePromise = generateCertificate({ - appDirectory: tempDir, - forceInstall: false, - platform: 'linux', - }) - - await expect(generatePromise).rejects.toThrow(AbortError) - expect(generateCertificatePrompt).not.toHaveBeenCalled() - expect(exec).not.toHaveBeenCalled() - }) + testWithTempDir( + 'fails without prompting when forceInstall is false and certificates are missing', + async ({tempDir}) => { + vi.mocked(generateCertificatePrompt).mockClear() + vi.mocked(exec).mockClear() + const generatePromise = generateCertificate({ + appDirectory: tempDir, + forceInstall: 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 diff --git a/packages/cli/README.md b/packages/cli/README.md index 60be6630998..764f6563de0 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -653,9 +653,10 @@ Run the app. ``` USAGE $ shopify app dev [--auth-alias ] [--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] + [--install-mkcert --use-localhost] [--localhost-port ] [--no-color] [--no-update] [--notify ] [--path + ] [--reset | ] [--skip-dependencies-installation] [-s ] [--store-password ] + [--subscription-product-url ] [-t ] [--theme-app-extension-port ] [--tunnel-url | ] + [--verbose] FLAGS -c, --config= @@ -682,6 +683,10 @@ FLAGS The Client ID of your app. [env: SHOPIFY_FLAG_CLIENT_ID] + --install-mkcert + Install and use mkcert to generate localhost certificates when --use-localhost is enabled without prompting. + [env: SHOPIFY_FLAG_INSTALL_MKCERT] + --localhost-port= Port to use for localhost. Must be between 1 and 65535. [env: SHOPIFY_FLAG_LOCALHOST_PORT] @@ -711,6 +716,10 @@ FLAGS Skips the installation of dependencies. Deprecated, use workspaces instead. [env: SHOPIFY_FLAG_SKIP_DEPENDENCIES_INSTALLATION] + --store-password= + The password for storefronts with password protection. + [env: SHOPIFY_FLAG_STORE_PASSWORD] + --subscription-product-url= Resource URL for subscription UI extension. Format: "/products/{productId}" [env: SHOPIFY_FLAG_SUBSCRIPTION_PRODUCT_URL]