diff --git a/docs-shopify.dev/commands/interfaces/app-config-link.interface.ts b/docs-shopify.dev/commands/interfaces/app-config-link.interface.ts index 61612d1b815..47d36fd63cb 100644 --- a/docs-shopify.dev/commands/interfaces/app-config-link.interface.ts +++ b/docs-shopify.dev/commands/interfaces/app-config-link.interface.ts @@ -16,6 +16,18 @@ export interface appconfiglink { */ '-c, --config '?: string + /** + * The name of the app configuration file to create or overwrite. + * @environment SHOPIFY_FLAG_APP_CONFIG_FILE_NAME + */ + '--file-name '?: string + + /** + * Overwrite an existing configuration file without prompting. + * @environment SHOPIFY_FLAG_FORCE + */ + '--force'?: '' + /** * Disable color output. * @environment SHOPIFY_FLAG_NO_COLOR diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 4965084de82..f707531a564 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -391,6 +391,24 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-config-link.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--file-name ", + "value": "string", + "description": "The name of the app configuration file to create or overwrite.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_APP_CONFIG_FILE_NAME" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-config-link.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--force", + "value": "''", + "description": "Overwrite an existing configuration file without prompting.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_FORCE" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-config-link.interface.ts", "syntaxKind": "PropertySignature", @@ -437,7 +455,7 @@ "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" } ], - "value": "export interface appconfiglink {\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 * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\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 * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface appconfiglink {\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 * The name of the app configuration file to create or overwrite.\n * @environment SHOPIFY_FLAG_APP_CONFIG_FILE_NAME\n */\n '--file-name '?: string\n\n /**\n * Overwrite an existing configuration file without prompting.\n * @environment SHOPIFY_FLAG_FORCE\n */\n '--force'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\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 * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appconfigpull": { diff --git a/packages/app/src/cli/commands/app/config/link.test.ts b/packages/app/src/cli/commands/app/config/link.test.ts new file mode 100644 index 00000000000..d54b4c231de --- /dev/null +++ b/packages/app/src/cli/commands/app/config/link.test.ts @@ -0,0 +1,42 @@ +import ConfigLink from './link.js' +import link from '../../../services/app/config/link.js' +import {linkedAppContext} from '../../../services/app-context.js' +import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../../services/app/config/link.js') +vi.mock('../../../services/app-context.js') + +describe('app config link command', () => { + test('accepts --client-id with --file-name to link a specific app to a specific config file', async () => { + await inTemporaryDirectory(async (tmp) => { + const app = testAppLinked() + vi.mocked(link).mockResolvedValue({ + remoteApp: testOrganizationApp(), + configFileName: 'shopify.app.staging.toml', + configuration: app.configuration, + }) + vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited>) + + await ConfigLink.run( + ['--path', tmp, '--client-id', 'api-key', '--file-name', 'staging', '--force'], + import.meta.url, + ) + + expect(link).toHaveBeenCalledWith({ + directory: tmp, + apiKey: 'api-key', + configName: undefined, + fileName: 'staging', + force: true, + }) + expect(linkedAppContext).toHaveBeenCalledWith({ + directory: tmp, + clientId: undefined, + forceRelink: false, + userProvidedConfigName: 'shopify.app.staging.toml', + }) + }) + }) +}) diff --git a/packages/app/src/cli/commands/app/config/link.ts b/packages/app/src/cli/commands/app/config/link.ts index 1783c844eba..c2772ad369d 100644 --- a/packages/app/src/cli/commands/app/config/link.ts +++ b/packages/app/src/cli/commands/app/config/link.ts @@ -3,6 +3,7 @@ import {linkedAppContext} from '../../../services/app-context.js' import link, {LinkOptions} from '../../../services/app/config/link.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' import {globalFlags} from '@shopify/cli-kit/node/cli' +import {Flags} from '@oclif/core' export default class ConfigLink extends AppLinkedCommand { static summary = 'Fetch your app configuration from the Developer Dashboard.' @@ -17,6 +18,18 @@ export default class ConfigLink extends AppLinkedCommand { static flags = { ...globalFlags, ...appFlags, + 'file-name': Flags.string({ + hidden: false, + description: 'The name of the app configuration file to create or overwrite.', + env: 'SHOPIFY_FLAG_APP_CONFIG_FILE_NAME', + exclusive: ['config'], + }), + force: Flags.boolean({ + hidden: false, + description: 'Overwrite an existing configuration file without prompting.', + env: 'SHOPIFY_FLAG_FORCE', + default: false, + }), } public async run(): Promise { @@ -26,6 +39,8 @@ export default class ConfigLink extends AppLinkedCommand { directory: flags.path, apiKey: flags['client-id'], configName: flags.config, + fileName: flags['file-name'], + force: flags.force, } const result = await link(options) 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 d06ff3ce9e8..821f518c807 100644 --- a/packages/app/src/cli/services/app/config/link.test.ts +++ b/packages/app/src/cli/services/app/config/link.test.ts @@ -115,6 +115,76 @@ describe('link', () => { }) }) + test('does not ask for a name when a file name is provided as a flag', async () => { + await inTemporaryDirectory(async (tmp) => { + // Given + const developerPlatformClient = buildDeveloperPlatformClient() + const options: LinkOptions = { + directory: tmp, + fileName: 'staging', + developerPlatformClient, + } + await mockLoadOpaqueAppWithApp(tmp) + vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient})) + + // When + const {configFileName} = await link(options) + + // Then + expect(selectConfigName).not.toHaveBeenCalled() + expect(configFileName).toBe('shopify.app.staging.toml') + expect(fileExistsSync(joinPath(tmp, 'shopify.app.staging.toml'))).toBeTruthy() + }) + }) + + test('throws when a file name is provided for an existing file without force', async () => { + await inTemporaryDirectory(async (tmp) => { + // Given + const developerPlatformClient = buildDeveloperPlatformClient() + const options: LinkOptions = { + directory: tmp, + fileName: 'staging', + developerPlatformClient, + } + writeFileSync(joinPath(tmp, 'shopify.app.staging.toml'), 'client_id = "12345"') + await mockLoadOpaqueAppWithApp(tmp) + vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient})) + + // When + const result = link(options) + + // Then + await expect(result).rejects.toThrow(/Configuration file shopify\.app\.staging\.toml already exists/) + expect(selectConfigName).not.toHaveBeenCalled() + }) + }) + + test('overwrites an existing file name when force is provided', async () => { + await inTemporaryDirectory(async (tmp) => { + // Given + const developerPlatformClient = buildDeveloperPlatformClient() + const options: LinkOptions = { + directory: tmp, + fileName: 'staging', + force: true, + developerPlatformClient, + } + writeFileSync(joinPath(tmp, 'shopify.app.staging.toml'), 'name = "old app"') + await mockLoadOpaqueAppWithApp(tmp) + vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient})) + + // When + const {configFileName} = await link(options) + + // Then + const content = await readFile(joinPath(tmp, 'shopify.app.staging.toml')) + expect(selectConfigName).not.toHaveBeenCalled() + expect(configFileName).toBe('shopify.app.staging.toml') + expect(content).toContain('client_id = "12345"') + expect(content).not.toContain('old app') + }) + }) + test('does not ask for a name when the selected app is already linked', 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 23d10efa0ca..6d036b9a856 100644 --- a/packages/app/src/cli/services/app/config/link.ts +++ b/packages/app/src/cli/services/app/config/link.ts @@ -26,6 +26,7 @@ import {loadLocalExtensionsSpecifications} from '../../../models/extensions/load import {renderSuccess} from '@shopify/cli-kit/node/ui' import {formatPackageManagerCommand} from '@shopify/cli-kit/node/output' import {deepMergeObjects, isEmpty} from '@shopify/cli-kit/common/object' +import {fileExists} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' import {AbortError} from '@shopify/cli-kit/node/error' import {PackageManager} from '@shopify/cli-kit/node/node-package-manager' @@ -36,6 +37,8 @@ export interface LinkOptions { appId?: string organizationId?: string configName?: string + fileName?: string + force?: boolean developerPlatformClient?: DeveloperPlatformClient isNewApp?: boolean } @@ -293,6 +296,18 @@ async function loadConfigurationFileName( appDirectory?: string }, ): Promise { + if (options.fileName) { + const fileName = getAppConfigurationFileName(options.fileName) + const appDirectory = localAppInfo.appDirectory ?? options.directory + if (!options.force && (await fileExists(joinPath(appDirectory, fileName)))) { + throw new AbortError( + `Configuration file ${fileName} already exists.`, + 'Run the command with --force to overwrite it.', + ) + } + return fileName + } + if (options.configName) { return getAppConfigurationFileName(options.configName) } diff --git a/packages/cli/README.md b/packages/cli/README.md index d96a305ea8a..9ec09d518df 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -241,11 +241,15 @@ Fetch your app configuration from the Developer Dashboard. ``` USAGE - $ shopify app config link [--client-id | -c ] [--no-color] [--path ] [--reset | ] [--verbose] + $ shopify app config link [--client-id | -c ] [--file-name | ] [--force] [--no-color] + [--path ] [--reset | ] [--verbose] FLAGS -c, --config= [env: SHOPIFY_FLAG_APP_CONFIG] The name of the app configuration. --client-id= [env: SHOPIFY_FLAG_CLIENT_ID] The Client ID of your app. + --file-name= [env: SHOPIFY_FLAG_APP_CONFIG_FILE_NAME] The name of the app configuration file to create or + overwrite. + --force [env: SHOPIFY_FLAG_FORCE] Overwrite an existing configuration file without prompting. --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. --path= [env: SHOPIFY_FLAG_PATH] The path to your app directory. --reset [env: SHOPIFY_FLAG_RESET] Reset all your settings. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index b73dd5a6473..ec976e30c41 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -461,6 +461,26 @@ "name": "config", "type": "option" }, + "file-name": { + "description": "The name of the app configuration file to create or overwrite.", + "env": "SHOPIFY_FLAG_APP_CONFIG_FILE_NAME", + "exclusive": [ + "config" + ], + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "file-name", + "type": "option" + }, + "force": { + "allowNo": false, + "description": "Overwrite an existing configuration file without prompting.", + "env": "SHOPIFY_FLAG_FORCE", + "hidden": false, + "name": "force", + "type": "boolean" + }, "no-color": { "allowNo": false, "description": "Disable color output.",