diff --git a/packages/app/src/cli/commands/app/init.ts b/packages/app/src/cli/commands/app/init.ts index 0954849647..ee31ea1150 100644 --- a/packages/app/src/cli/commands/app/init.ts +++ b/packages/app/src/cli/commands/app/init.ts @@ -10,7 +10,7 @@ import {appNamePrompt, createAsNewAppPrompt, selectAppPrompt} from '../../prompt import {searchForAppsByNameFactory} from '../../services/dev/prompt-helpers.js' import {isValidName} from '../../models/app/validation/common.js' import {Flags} from '@oclif/core' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' import {resolvePath, cwd} from '@shopify/cli-kit/node/path' import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' @@ -38,12 +38,14 @@ export default class Init extends AppLinkedCommand { default: async () => cwd(), hidden: false, }), - template: Flags.string({ - description: `The app template. Accepts one of the following: + template: requiredIfNonInteractive( + 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', - }), + env: 'SHOPIFY_FLAG_TEMPLATE', + }), + ), flavor: Flags.string({ description: 'Which flavor of the given template to use.', env: 'SHOPIFY_FLAG_TEMPLATE_FLAVOR', diff --git a/packages/cli-kit/src/public/node/base-command.ts b/packages/cli-kit/src/public/node/base-command.ts index 2cb2cf4b5a..a1a0ae8b19 100644 --- a/packages/cli-kit/src/public/node/base-command.ts +++ b/packages/cli-kit/src/public/node/base-command.ts @@ -105,6 +105,7 @@ abstract class BaseCommand extends Command { let result = await super.parse(options, argv) result = await this.resultWithEnvironment(result, options, argv) await addFromParsedFlags(result.flags) + this.failMissingNonTTYFlags(result.flags, this.nonInteractiveRequiredFlags()) return {...result, ...{argv: result.argv as string[]}} } @@ -130,6 +131,17 @@ This flag is required in non-interactive terminal environments, such as a CI env }) } + // Collects the names of flags marked with the `requiredIfNonInteractive` factory (see + // `requiredIfNonInteractive` in `cli.ts`). The custom property is read from the live command class + // rather than the cached manifest, where it would have been stripped. + private nonInteractiveRequiredFlags(): string[] { + const ctor = this.constructor as unknown as {flags?: FlagInput; baseFlags?: FlagInput} + const allFlags = {...ctor.baseFlags, ...ctor.flags} + return Object.entries(allFlags) + .filter(([, flag]) => Boolean((flag as {requiredIfNonInteractive?: boolean}).requiredIfNonInteractive)) + .map(([name]) => name) + } + private async resultWithEnvironment< TFlags extends FlagOutput & {path?: string; verbose?: boolean}, TGlobalFlags extends FlagOutput, diff --git a/packages/cli-kit/src/public/node/cli.ts b/packages/cli-kit/src/public/node/cli.ts index 10a6a2eb00..54b4ed9488 100644 --- a/packages/cli-kit/src/public/node/cli.ts +++ b/packages/cli-kit/src/public/node/cli.ts @@ -166,6 +166,32 @@ export const portFlag = (options: {description?: string; env?: string; hidden?: return Flags.integer({min: 1, max: 65535, ...options, description}) } +/** + * Marks a flag as required when the CLI runs in a non-interactive terminal (e.g. CI, or piped input). + * + * The flag stays optional in interactive sessions, where the command can prompt for the value. In + * non-interactive sessions `BaseCommand` fails with a clear error if the flag is missing. The factory + * also prepends `(required if non-interactive)` to the flag's description so the requirement shows up + * in `--help`, mirroring how oclif renders `(required)`. + * + * Wrap any oclif flag definition with it, for example: + * + * ``` + * template: requiredIfNonInteractive(Flags.string({description: 'The app template.'})) + * ``` + * @param flag - A flag definition created with `Flags.string`, `Flags.boolean`, etc. + * @returns The same flag definition, annotated for non-interactive validation and help rendering. + */ +export function requiredIfNonInteractive(flag: TFlag): TFlag { + // Mutate the freshly-built flag in place: this keeps the original flag type intact (so command + // flag typings are unchanged) while attaching a custom property the parser ignores but + // `BaseCommand` reads from the live command class at parse time. + const annotated = flag as TFlag & {requiredIfNonInteractive?: boolean} + annotated.requiredIfNonInteractive = true + annotated.description = ['(required if non-interactive)', flag.description].filter(Boolean).join(' ') + return annotated +} + /** * Clear the CLI cache, used to store some API responses and handle notifications status */ diff --git a/packages/cli/README.md b/packages/cli/README.md index f7d6f3620d..0dc6d50f94 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -852,7 +852,8 @@ FLAGS --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. --organization-id= [env: SHOPIFY_FLAG_ORGANIZATION_ID] The organization ID. Your organization ID can be found in your Dev Dashboard URL: https://dev.shopify.com/dashboard/ - --template= [env: SHOPIFY_FLAG_TEMPLATE] The app template. Accepts one of the following: + --template= [env: SHOPIFY_FLAG_TEMPLATE] (required if non-interactive) The app template. Accepts + one of the following: - - Any GitHub repo with optional branch and subpath, e.g., https://github.com/Shopify//[subpath]#[branch] diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index eeacfe2b75..3d144ce881 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -2499,7 +2499,7 @@ "type": "option" }, "template": { - "description": "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]", + "description": "(required if non-interactive) 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]", "env": "SHOPIFY_FLAG_TEMPLATE", "hasDynamicHelp": false, "multiple": false, diff --git a/packages/create-app/oclif.manifest.json b/packages/create-app/oclif.manifest.json index cd0d4e684b..0929e76c33 100644 --- a/packages/create-app/oclif.manifest.json +++ b/packages/create-app/oclif.manifest.json @@ -90,7 +90,7 @@ "type": "option" }, "template": { - "description": "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]", + "description": "(required if non-interactive) 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]", "env": "SHOPIFY_FLAG_TEMPLATE", "hasDynamicHelp": false, "multiple": false,