diff --git a/docs-shopify.dev/commands/interfaces/app-init.interface.ts b/docs-shopify.dev/commands/interfaces/app-init.interface.ts index db3e16ce335..7976b8567af 100644 --- a/docs-shopify.dev/commands/interfaces/app-init.interface.ts +++ b/docs-shopify.dev/commands/interfaces/app-init.interface.ts @@ -1,5 +1,11 @@ // This is an autogenerated file. Don't edit this file manually. export interface appinit { + /** + * The Client ID of your app. Use this to automatically link your new project to an existing app. Using this flag avoids the app selection prompt. + * @environment SHOPIFY_FLAG_CLIENT_ID + */ + '--client-id '?: string + /** * Which flavor of the given template to use. * @environment SHOPIFY_FLAG_TEMPLATE_FLAVOR diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index cc14d4a74bd..e070d9f46be 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -1498,6 +1498,15 @@ "name": "appinit", "description": "", "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/app-init.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--client-id ", + "value": "string", + "description": "The Client ID of your app. Use this to automatically link your new project to an existing app. Using this flag avoids the app selection prompt.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-init.interface.ts", "syntaxKind": "PropertySignature", @@ -1562,7 +1571,7 @@ "environmentValue": "SHOPIFY_FLAG_PATH" } ], - "value": "export interface appinit {\n /**\n * Which flavor of the given template to use.\n * @environment SHOPIFY_FLAG_TEMPLATE_FLAVOR\n */\n '--flavor '?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_NAME\n */\n '-n, --name '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * \n * @environment SHOPIFY_FLAG_PACKAGE_MANAGER\n */\n '-d, --package-manager '?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_PATH\n */\n '-p, --path '?: string\n\n /**\n * The app template. Accepts one of the following:\n - \n - Any GitHub repo with optional branch and subpath, e.g., https://github.com/Shopify//[subpath]#[branch]\n * @environment SHOPIFY_FLAG_TEMPLATE\n */\n '--template '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface appinit {\n /**\n * The Client ID of your app. Use this to automatically link your new project to an existing app. Using this flag avoids the app selection prompt.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * Which flavor of the given template to use.\n * @environment SHOPIFY_FLAG_TEMPLATE_FLAVOR\n */\n '--flavor '?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_NAME\n */\n '-n, --name '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * \n * @environment SHOPIFY_FLAG_PACKAGE_MANAGER\n */\n '-d, --package-manager '?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_PATH\n */\n '-p, --path '?: string\n\n /**\n * The app template. Accepts one of the following:\n - \n - Any GitHub repo with optional branch and subpath, e.g., https://github.com/Shopify//[subpath]#[branch]\n * @environment SHOPIFY_FLAG_TEMPLATE\n */\n '--template '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } } } diff --git a/package.json b/package.json index 36b6070a885..aabc0be59ae 100644 --- a/package.json +++ b/package.json @@ -158,7 +158,8 @@ "pin-github-action", "react", "@graphql-codegen/near-operation-file-preset", - "graphql-codegen-typescript-operation-types" + "graphql-codegen-typescript-operation-types", + "esbuild" ], "ignore": [ "configurations/vite.config.ts" @@ -191,8 +192,7 @@ ], "project": "**/*.ts!", "ignoreDependencies": [ - "@ast-grep/napi", - "@parcel/watcher" + "@ast-grep/napi" ], "vite": { "config": [ diff --git a/packages/app/package.json b/packages/app/package.json index 1df04e5f60e..c6bbad0486e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -52,7 +52,6 @@ "@luckycatfactory/esbuild-graphql-loader": "3.8.1", "@oclif/core": "3.26.5", "@shopify/cli-kit": "3.67.0", - "@shopify/create-app": "3.67.0", "@shopify/theme": "3.67.0", "@shopify/function-runner": "4.1.1", "@shopify/plugin-cloudflare": "3.67.0", diff --git a/packages/app/src/cli/commands/app/init.ts b/packages/app/src/cli/commands/app/init.ts index 269e59b8cc9..8522eeb1ea3 100644 --- a/packages/app/src/cli/commands/app/init.ts +++ b/packages/app/src/cli/commands/app/init.ts @@ -1,2 +1,120 @@ -// The init command is re-exported from @shopify/create-app to reuse all the logic from there -export {default} from '@shopify/create-app/commands/init' +import initPrompt, {visibleTemplates} from '../../prompts/init/init.js' +import initService from '../../services/init/init.js' +import {selectDeveloperPlatformClient} from '../../utilities/developer-platform-client.js' +import {appFromId, selectOrg} from '../../services/context.js' +import {selectOrCreateApp} from '../../services/dev/select-app.js' +import AppCommand from '../../utilities/app-command.js' +import {validateFlavorValue, validateTemplateValue} from '../../services/init/validate.js' +import {OrganizationApp} from '../../models/organization.js' +import {Flags} from '@oclif/core' +import {globalFlags} from '@shopify/cli-kit/node/cli' +import {resolvePath, cwd} from '@shopify/cli-kit/node/path' +import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' + +import {installGlobalShopifyCLI} from '@shopify/cli-kit/node/is-global' +import {generateRandomNameForSubdirectory} from '@shopify/cli-kit/node/fs' +import {renderText} from '@shopify/cli-kit/node/ui' +import {inferPackageManager} from '@shopify/cli-kit/node/node-package-manager' + +export default class Init extends AppCommand { + static summary?: string | undefined = 'Create a new app project' + + static flags = { + ...globalFlags, + name: Flags.string({ + char: 'n', + env: 'SHOPIFY_FLAG_NAME', + hidden: false, + }), + path: Flags.string({ + char: 'p', + env: 'SHOPIFY_FLAG_PATH', + parse: async (input) => resolvePath(input), + default: async () => cwd(), + hidden: false, + }), + template: Flags.string({ + description: `The app template. Accepts one of the following: + - <${visibleTemplates.join('|')}> + - Any GitHub repo with optional branch and subpath, e.g., https://github.com/Shopify//[subpath]#[branch]`, + env: 'SHOPIFY_FLAG_TEMPLATE', + }), + flavor: Flags.string({ + description: 'Which flavor of the given template to use.', + env: 'SHOPIFY_FLAG_TEMPLATE_FLAVOR', + }), + 'package-manager': Flags.string({ + char: 'd', + env: 'SHOPIFY_FLAG_PACKAGE_MANAGER', + hidden: false, + options: ['npm', 'yarn', 'pnpm', 'bun'], + }), + local: Flags.boolean({ + char: 'l', + env: 'SHOPIFY_FLAG_LOCAL', + default: false, + hidden: true, + }), + 'client-id': Flags.string({ + hidden: false, + description: + 'The Client ID of your app. Use this to automatically link your new project to an existing app. Using this flag avoids the app selection prompt.', + env: 'SHOPIFY_FLAG_CLIENT_ID', + exclusive: ['config'], + }), + } + + async run(): Promise { + const {flags} = await this.parse(Init) + + validateTemplateValue(flags.template) + validateFlavorValue(flags.template, flags.flavor) + + const inferredPackageManager = inferPackageManager(flags['package-manager']) + const name = flags.name ?? (await generateRandomNameForSubdirectory({suffix: 'app', directory: flags.path})) + + // Authenticate and select organization and app + const developerPlatformClient = selectDeveloperPlatformClient() + + let selectedApp: OrganizationApp + if (flags['client-id']) { + // If a client-id is provided we don't need to prompt the user and can link directly to that app. + selectedApp = await appFromId({apiKey: flags['client-id'], developerPlatformClient}) + } else { + renderText({text: "\nWelcome. Let's get started by linking this new project to an app in your organization."}) + const org = await selectOrg() + const {organization, apps, hasMorePages} = await developerPlatformClient.orgAndApps(org.id) + selectedApp = await selectOrCreateApp(name, apps, hasMorePages, organization, developerPlatformClient) + } + + const promptAnswers = await initPrompt({ + template: flags.template, + flavor: flags.flavor, + }) + + if (promptAnswers.globalCLIResult.install) { + await installGlobalShopifyCLI(inferredPackageManager) + } + + await addPublicMetadata(() => ({ + cmd_create_app_template: promptAnswers.templateType, + cmd_create_app_template_url: promptAnswers.template, + })) + + const platformClient = selectedApp.developerPlatformClient ?? developerPlatformClient + + await initService({ + name: selectedApp.title, + app: selectedApp, + packageManager: inferredPackageManager, + template: promptAnswers.template, + local: flags.local, + directory: flags.path, + useGlobalCLI: promptAnswers.globalCLIResult.alreadyInstalled || promptAnswers.globalCLIResult.install, + developerPlatformClient: platformClient, + postCloneActions: { + removeLockfilesFromGitignore: promptAnswers.templateType !== 'custom', + }, + }) + } +} diff --git a/packages/create-app/src/prompts/init.test.ts b/packages/app/src/cli/prompts/init/init.test.ts similarity index 56% rename from packages/create-app/src/prompts/init.test.ts rename to packages/app/src/cli/prompts/init/init.test.ts index 04b26cce5f6..0a3ff6a0ffc 100644 --- a/packages/create-app/src/prompts/init.test.ts +++ b/packages/app/src/cli/prompts/init/init.test.ts @@ -1,6 +1,6 @@ import init, {InitOptions} from './init.js' import {describe, expect, vi, test, beforeEach} from 'vitest' -import {renderSelectPrompt, renderText, renderTextPrompt} from '@shopify/cli-kit/node/ui' +import {renderSelectPrompt} from '@shopify/cli-kit/node/ui' import {installGlobalCLIPrompt} from '@shopify/cli-kit/node/is-global' vi.mock('@shopify/cli-kit/node/ui') @@ -12,56 +12,14 @@ describe('init', () => { beforeEach(() => { vi.mocked(installGlobalCLIPrompt).mockResolvedValue(globalCLIResult) }) - test('when name is not passed', async () => { - const answers = { - name: 'app', - } - const options: InitOptions = {template: 'template', directory: '/'} - - // Given - vi.mocked(renderTextPrompt).mockResolvedValueOnce(answers.name) - - // When - const got = await init(options) - - // Then - expect(renderText).toHaveBeenCalledWith({ - text: '\nWelcome. Let’s get started by naming your app project. You can change it later.', - }) - expect(renderTextPrompt).toHaveBeenCalledWith({ - message: 'Your project name?', - defaultValue: expect.stringMatching(/^\w+-\w+-app$/), - validate: expect.any(Function), - }) - expect(got).toEqual({...options, ...answers, templateType: 'custom', globalCLIResult}) - }) - - test('when name is passed', async () => { - const answers = { - template: 'https://github.com/Shopify/shopify-app-template-remix', - } - const options: InitOptions = {name: 'app', directory: '/'} - - // When - const got = await init(options) - - // Then - expect(renderText).toHaveBeenCalledWith({ - text: '\nWelcome. Let’s get started by choosing a template for your app project.', - }) - expect(renderTextPrompt).not.toHaveBeenCalled() - expect(got).toEqual({...options, ...answers, templateType: 'custom', globalCLIResult}) - }) test('it renders the label for the template options', async () => { const answers = { - name: 'app', template: 'https://github.com/Shopify/shopify-app-template-none', } - const options: InitOptions = {directory: '/'} + const options: InitOptions = {} // Given - vi.mocked(renderTextPrompt).mockResolvedValueOnce(answers.name) vi.mocked(renderSelectPrompt).mockResolvedValueOnce('none') // When @@ -81,13 +39,11 @@ describe('init', () => { test('it renders branches for templates that have them', async () => { const answers = { - name: 'app', template: 'https://github.com/Shopify/shopify-app-template-remix#javascript', } - const options: InitOptions = {directory: '/'} + const options: InitOptions = {} // Given - vi.mocked(renderTextPrompt).mockResolvedValueOnce(answers.name) vi.mocked(renderSelectPrompt).mockResolvedValueOnce('remix') vi.mocked(renderSelectPrompt).mockResolvedValueOnce('javascript') diff --git a/packages/create-app/src/prompts/init.ts b/packages/app/src/cli/prompts/init/init.ts similarity index 76% rename from packages/create-app/src/prompts/init.ts rename to packages/app/src/cli/prompts/init/init.ts index 75af0b3310e..45a5ebd0e8a 100644 --- a/packages/create-app/src/prompts/init.ts +++ b/packages/app/src/cli/prompts/init/init.ts @@ -1,16 +1,12 @@ -import {generateRandomNameForSubdirectory} from '@shopify/cli-kit/node/fs' import {InstallGlobalCLIPromptResult, installGlobalCLIPrompt} from '@shopify/cli-kit/node/is-global' -import {renderText, renderSelectPrompt, renderTextPrompt} from '@shopify/cli-kit/node/ui' +import {renderSelectPrompt} from '@shopify/cli-kit/node/ui' export interface InitOptions { - name?: string template?: string flavor?: string - directory: string } interface InitOutput { - name: string template: string // e.g. 'remix' templateType: PredefinedTemplate | 'custom' @@ -21,6 +17,7 @@ interface TemplateBranch { branch: string label: string } + interface Template { url: string label?: string @@ -72,42 +69,14 @@ export const visibleTemplates = allTemplates.filter((key) => templates[key].visi const templateOptionsInOrder = ['remix', 'none'] as const const init = async (options: InitOptions): Promise => { - let name = options.name let template = options.template const flavor = options.flavor const defaults = { - name: await generateRandomNameForSubdirectory({suffix: 'app', directory: options.directory}), template: templates.remix.url, } as const - let welcomed = false - - if (!name) { - renderText({text: '\nWelcome. Let’s get started by naming your app project. You can change it later.'}) - welcomed = true - name = await renderTextPrompt({ - message: 'Your project name?', - defaultValue: defaults.name, - validate: (value) => { - if (value.length === 0) { - return "App name can't be empty" - } - if (value.length > 30) { - return 'Enter a shorter name (30 character max.)' - } - if (value.toLowerCase().includes('shopify')) { - return "App name can't include the word 'shopify'" - } - }, - }) - } - if (!template) { - if (!welcomed) { - renderText({text: '\nWelcome. Let’s get started by choosing a template for your app project.'}) - welcomed = true - } template = await renderSelectPrompt({ choices: templateOptionsInOrder.map((key) => { return { @@ -122,7 +91,6 @@ const init = async (options: InitOptions): Promise => { const answers: InitOutput = { ...options, - name, template, templateType: isPredefinedTemplate(template) ? template : 'custom', globalCLIResult: {install: false, alreadyInstalled: false}, diff --git a/packages/app/src/cli/services/app/config/link.ts b/packages/app/src/cli/services/app/config/link.ts index ec2cc3b2d6b..ba7167207ba 100644 --- a/packages/app/src/cli/services/app/config/link.ts +++ b/packages/app/src/cli/services/app/config/link.ts @@ -42,6 +42,8 @@ import {PackageManager} from '@shopify/cli-kit/node/node-package-manager' export interface LinkOptions { directory: string apiKey?: string + appId?: string + organizationId?: string configName?: string baseConfigName?: string developerPlatformClient?: DeveloperPlatformClient @@ -113,7 +115,12 @@ async function selectOrCreateRemoteAppToLinkTo(options: LinkOptions): Promise<{ if (options.apiKey) { // Remote API Key provided by the caller, so use that app specifically - const remoteApp = await appFromId({apiKey: options.apiKey, developerPlatformClient}) + const remoteApp = await appFromId({ + apiKey: options.apiKey, + id: options.appId, + developerPlatformClient, + organizationId: options.organizationId, + }) if (!remoteApp) { const errorMessage = InvalidApiKeyErrorMessage(options.apiKey) throw new AbortError(errorMessage.message, errorMessage.tryMessage) diff --git a/packages/app/src/cli/services/dev/select-app.ts b/packages/app/src/cli/services/dev/select-app.ts index ef89d386add..ca379b7b97f 100644 --- a/packages/app/src/cli/services/dev/select-app.ts +++ b/packages/app/src/cli/services/dev/select-app.ts @@ -4,7 +4,6 @@ import {Organization, MinimalOrganizationApp, OrganizationApp} from '../../model import {getCachedCommandInfo, setCachedCommandTomlPreference} from '../local-storage.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {AppConfigurationFileName} from '../../models/app/loader.js' -import {outputInfo} from '@shopify/cli-kit/node/output' /** * Select an app from env, list or create a new one: @@ -29,7 +28,6 @@ export async function selectOrCreateApp( ): Promise { let createNewApp = apps.length === 0 if (!createNewApp) { - outputInfo(`\nBefore proceeding, your project needs to be associated with an app.\n`) createNewApp = await createAsNewAppPrompt() } if (createNewApp) { diff --git a/packages/create-app/src/services/init.ts b/packages/app/src/cli/services/init/init.ts similarity index 91% rename from packages/create-app/src/services/init.ts rename to packages/app/src/cli/services/init/init.ts index 2a4a998e6ac..5a06186d6a6 100644 --- a/packages/create-app/src/services/init.ts +++ b/packages/app/src/cli/services/init/init.ts @@ -1,5 +1,8 @@ -import {getDeepInstallNPMTasks, updateCLIDependencies} from '../utils/template/npm.js' -import cleanup from '../utils/template/cleanup.js' +import {getDeepInstallNPMTasks, updateCLIDependencies} from './template/npm.js' +import cleanup from './template/cleanup.js' +import link from '../app/config/link.js' +import {OrganizationApp} from '../../models/organization.js' +import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import { findUpAndReadPackageJson, lockfiles, @@ -31,11 +34,13 @@ import {LocalStorage} from '@shopify/cli-kit/node/local-storage' interface InitOptions { name: string + app: OrganizationApp directory: string template: string packageManager: PackageManager local: boolean useGlobalCLI: boolean + developerPlatformClient: DeveloperPlatformClient postCloneActions: { removeLockfilesFromGitignore: boolean } @@ -188,6 +193,19 @@ async function init(options: InitOptions) { await moveFile(templateScaffoldDir, outputDirectory) }) + // Link the new project to the selected App + await link( + { + directory: outputDirectory, + apiKey: options.app.apiKey, + appId: options.app.id, + organizationId: options.app.organizationId, + configName: 'shopify.app.toml', + developerPlatformClient: options.developerPlatformClient, + }, + false, + ) + renderSuccess({ headline: [{userInput: hyphenizedName}, 'is ready for you to build!'], nextSteps: [ diff --git a/packages/create-app/src/utils/template/cleanup.test.ts b/packages/app/src/cli/services/init/template/cleanup.test.ts similarity index 100% rename from packages/create-app/src/utils/template/cleanup.test.ts rename to packages/app/src/cli/services/init/template/cleanup.test.ts diff --git a/packages/create-app/src/utils/template/cleanup.ts b/packages/app/src/cli/services/init/template/cleanup.ts similarity index 100% rename from packages/create-app/src/utils/template/cleanup.ts rename to packages/app/src/cli/services/init/template/cleanup.ts diff --git a/packages/create-app/src/utils/template/npm.test.ts b/packages/app/src/cli/services/init/template/npm.test.ts similarity index 100% rename from packages/create-app/src/utils/template/npm.test.ts rename to packages/app/src/cli/services/init/template/npm.test.ts diff --git a/packages/create-app/src/utils/template/npm.ts b/packages/app/src/cli/services/init/template/npm.ts similarity index 100% rename from packages/create-app/src/utils/template/npm.ts rename to packages/app/src/cli/services/init/template/npm.ts diff --git a/packages/app/src/cli/services/init/validate.test.ts b/packages/app/src/cli/services/init/validate.test.ts new file mode 100644 index 00000000000..e031dc46ca2 --- /dev/null +++ b/packages/app/src/cli/services/init/validate.test.ts @@ -0,0 +1,55 @@ +import {validateTemplateValue, validateFlavorValue} from './validate.js' +import {describe, expect, test} from 'vitest' +import {AbortError} from '@shopify/cli-kit/node/error' + +describe('validateTemplateValue', () => { + test('should not throw an error for undefined template', () => { + expect(() => validateTemplateValue(undefined)).not.toThrow() + }) + + test('should not throw an error for valid GitHub URL', () => { + expect(() => validateTemplateValue('https://github.com/Shopify/example')).not.toThrow() + }) + + test('should throw an AbortError for non-GitHub URL', () => { + expect(() => validateTemplateValue('https://gitlab.com/example')).toThrow(AbortError) + }) + + test('should not throw an error for valid predefined template', () => { + expect(() => validateTemplateValue('node')).not.toThrow() + }) + + test('should throw an AbortError for invalid template', () => { + expect(() => validateTemplateValue('invalid-template')).toThrow(AbortError) + }) +}) + +describe('validateFlavorValue', () => { + test('should not throw an error when both template and flavor are undefined', () => { + expect(() => validateFlavorValue(undefined, undefined)).not.toThrow() + }) + + test('should throw an AbortError when flavor is provided without template', () => { + expect(() => validateFlavorValue(undefined, 'some-flavor')).toThrow(AbortError) + }) + + test('should not throw an error when template is provided without flavor', () => { + expect(() => validateFlavorValue('node', undefined)).not.toThrow() + }) + + test('should throw an AbortError when flavor is provided for custom template', () => { + expect(() => validateFlavorValue('https://github.com/custom/template', 'some-flavor')).toThrow(AbortError) + }) + + test('should throw an AbortError when template does not support flavors', () => { + expect(() => validateFlavorValue('ruby', 'some-flavor')).toThrow(AbortError) + }) + + test('should throw an AbortError for invalid flavor option', () => { + expect(() => validateFlavorValue('remix', 'invalid-flavor')).toThrow(AbortError) + }) + + test('should not throw an error for valid template and flavor combination', () => { + expect(() => validateFlavorValue('remix', 'javascript')).not.toThrow() + }) +}) diff --git a/packages/app/src/cli/services/init/validate.ts b/packages/app/src/cli/services/init/validate.ts new file mode 100644 index 00000000000..89d2dcee7ee --- /dev/null +++ b/packages/app/src/cli/services/init/validate.ts @@ -0,0 +1,63 @@ +import {isPredefinedTemplate, templates, visibleTemplates} from '../../prompts/init/init.js' +import {safeParseURL} from '@shopify/cli-kit/common/url' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' + +export function validateTemplateValue(template: string | undefined) { + if (!template) { + return + } + + const url = safeParseURL(template) + if (url && url.origin !== 'https://github.com') + throw new AbortError( + 'Only GitHub repository references are supported, ' + + 'e.g., https://github.com/Shopify//[subpath]#[branch]', + ) + if (!url && !isPredefinedTemplate(template)) + throw new AbortError( + outputContent`Only ${visibleTemplates + .map((alias) => outputContent`${outputToken.yellow(alias)}`.value) + .join(', ')} template aliases are supported, please provide a valid URL`, + ) +} + +export function validateFlavorValue(template: string | undefined, flavor: string | undefined) { + if (!template) { + if (flavor) { + throw new AbortError( + outputContent`The ${outputToken.yellow('--flavor')} flag requires the ${outputToken.yellow( + '--template', + )} flag to be set`, + ) + } else { + return + } + } + + if (!flavor) { + return + } + + if (!isPredefinedTemplate(template)) { + throw new AbortError( + outputContent`The ${outputToken.yellow('--flavor')} flag is not supported for custom templates`, + ) + } + + const templateConfig = templates[template] + + if (!templateConfig.branches) { + throw new AbortError(outputContent`The ${outputToken.yellow(template)} template does not support flavors`) + } + + if (!templateConfig.branches.options[flavor]) { + throw new AbortError( + outputContent`Invalid option for ${outputToken.yellow('--flavor')}\nThe ${outputToken.yellow( + '--flavor', + )} flag for ${outputToken.yellow(template)} accepts only ${Object.keys(templateConfig.branches.options) + .map((alias) => outputContent`${outputToken.yellow(alias)}`.value) + .join(', ')}`, + ) + } +} diff --git a/packages/cli-kit/src/public/common/url.test.ts b/packages/cli-kit/src/public/common/url.test.ts index 09fc98956e6..c3dea50d3ec 100644 --- a/packages/cli-kit/src/public/common/url.test.ts +++ b/packages/cli-kit/src/public/common/url.test.ts @@ -1,4 +1,4 @@ -import {isValidURL} from './url.js' +import {isValidURL, safeParseURL} from './url.js' import {describe, expect, test} from 'vitest' describe('isValidURL', () => { @@ -34,3 +34,26 @@ describe('isValidURL', () => { expect(got).toBe(false) }) }) + +describe('safeParseURL', () => { + test('returns URL object for valid URL', () => { + const validURL = 'https://shopify.com/' + const result = safeParseURL(validURL) + + expect(result).toBeInstanceOf(URL) + expect(result?.href).toBe(validURL) + }) + + test('returns undefined for invalid URL', () => { + const invalidURL = 'not a url' + const result = safeParseURL(invalidURL) + + expect(result).toBeUndefined() + }) + + test('returns undefined for empty string', () => { + const result = safeParseURL('') + + expect(result).toBeUndefined() + }) +}) diff --git a/packages/cli-kit/src/public/common/url.ts b/packages/cli-kit/src/public/common/url.ts index 09b51820f19..6a6086efe86 100644 --- a/packages/cli-kit/src/public/common/url.ts +++ b/packages/cli-kit/src/public/common/url.ts @@ -13,3 +13,18 @@ export function isValidURL(url: string): boolean { throw error } } + +/** + * Safely parse a string into a URL. + * + * @param url - The string to parse into a URL. + * @returns A URL object if the parsing is successful, undefined otherwise. + */ +export function safeParseURL(url: string): URL | undefined { + try { + return new URL(url) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + return undefined + } +} diff --git a/packages/cli-kit/src/public/node/node-package-manager.test.ts b/packages/cli-kit/src/public/node/node-package-manager.test.ts index 4d785ddc083..2f8213ecff5 100644 --- a/packages/cli-kit/src/public/node/node-package-manager.test.ts +++ b/packages/cli-kit/src/public/node/node-package-manager.test.ts @@ -18,10 +18,13 @@ import { PackageJsonNotFoundError, UnknownPackageManagerError, checkForCachedNewVersion, + inferPackageManager, + PackageManager, } from './node-package-manager.js' import {captureOutput, exec} from './system.js' import {inTemporaryDirectory, mkdir, touchFile, writeFile} from './fs.js' import {joinPath, dirname, normalizePath} from './path.js' +import {inferPackageManagerForGlobalCLI} from './is-global.js' import {cacheClear} from '../../private/node/conf-store.js' import latestVersion from 'latest-version' import {vi, describe, test, expect, beforeEach, afterEach} from 'vitest' @@ -29,6 +32,7 @@ import {vi, describe, test, expect, beforeEach, afterEach} from 'vitest' vi.mock('../../version.js') vi.mock('./system.js') vi.mock('latest-version') +vi.mock('./is-global') const mockedExec = vi.mocked(exec) const mockedCaptureOutput = vi.mocked(captureOutput) @@ -992,3 +996,34 @@ describe('addNPMDependencies', () => { }) }) }) + +describe('inferPackageManager', () => { + test('returns the package manager when a valid one is provided in options', () => { + expect(inferPackageManager('yarn')).toBe('yarn') + expect(inferPackageManager('npm')).toBe('npm') + expect(inferPackageManager('pnpm')).toBe('pnpm') + expect(inferPackageManager('bun')).toBe('bun') + }) + + test('ignores invalid package manager in options', () => { + const mockEnv = {npm_config_user_agent: 'npm/1.0.0'} + expect(inferPackageManager('invalid' as PackageManager, mockEnv)).toBe('npm') + }) + + test('infers package manager from user agent when not provided in options', () => { + const mockEnv = {npm_config_user_agent: 'yarn/1.22.0'} + expect(inferPackageManager(undefined, mockEnv)).toBe('yarn') + }) + + test('infers package manager from global CLI when not in options or user agent', () => { + const mockEnv = {} + vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('pnpm') + expect(inferPackageManager(undefined, mockEnv)).toBe('pnpm') + }) + + test('defaults to npm when no other method succeeds', () => { + const mockEnv = {} + vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('unknown') + expect(inferPackageManager(undefined, mockEnv)).toBe('npm') + }) +}) diff --git a/packages/cli-kit/src/public/node/node-package-manager.ts b/packages/cli-kit/src/public/node/node-package-manager.ts index a5ac87d3a56..118998f77a3 100644 --- a/packages/cli-kit/src/public/node/node-package-manager.ts +++ b/packages/cli-kit/src/public/node/node-package-manager.ts @@ -4,6 +4,7 @@ import {captureOutput, exec} from './system.js' import {fileExists, readFile, writeFile, findPathUp, glob} from './fs.js' import {dirname, joinPath} from './path.js' import {runWithTimer} from './metadata.js' +import {inferPackageManagerForGlobalCLI} from './is-global.js' import {outputToken, outputContent, outputDebug} from '../../public/node/output.js' import {PackageVersionKey, cacheRetrieve, cacheRetrieveOrRepopulate} from '../../private/node/conf-store.js' import latestVersion from 'latest-version' @@ -706,3 +707,28 @@ export async function writePackageJSON(directory: string, packageJSON: PackageJs const packagePath = joinPath(directory, 'package.json') await writeFile(packagePath, JSON.stringify(packageJSON, null, 2)) } + +/** + * Infers the package manager to be used based on the provided options and environment. + * + * This function determines the package manager in the following order of precedence: + * 1. Uses the package manager specified in the options, if valid. + * 2. Infers the package manager from the user agent string. + * 3. Infers the package manager used for the global CLI installation. + * 4. Defaults to 'npm' if no other method succeeds. + * + * @param optionsPackageManager - The package manager specified in the options (if any). + * @returns The inferred package manager as a PackageManager type. + */ +export function inferPackageManager(optionsPackageManager: string | undefined, env = process.env): PackageManager { + if (optionsPackageManager && packageManager.includes(optionsPackageManager as PackageManager)) { + return optionsPackageManager as PackageManager + } + const usedPackageManager = packageManagerFromUserAgent(env) + if (usedPackageManager !== 'unknown') return usedPackageManager + + const globalPackageManager = inferPackageManagerForGlobalCLI() + if (globalPackageManager !== 'unknown') return globalPackageManager + + return 'npm' +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 985538ce679..253186c661c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -527,13 +527,15 @@ Create a new app project ``` USAGE - $ shopify app init [--flavor ] [-n ] [--no-color] [-d npm|yarn|pnpm|bun] [-p ] - [--template ] [--verbose] + $ shopify app init [--client-id | ] [--flavor ] [-n ] [--no-color] [-d + npm|yarn|pnpm|bun] [-p ] [--template ] [--verbose] FLAGS -d, --package-manager=