Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clear-agents-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli-kit': minor
---

Show and validate all flags required for non-interactive commands before execution.
129 changes: 127 additions & 2 deletions packages/cli-kit/src/public/node/base-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Command from './base-command.js'
import {Environments} from './environments.js'
import {encodeToml as encodeTOML} from './toml/codec.js'
import {globalFlags} from './cli.js'
import {globalFlags, requiredIfNonInteractive} from './cli.js'
import {inTemporaryDirectory, mkdir, writeFile} from './fs.js'
import {joinPath, resolvePath, cwd} from './path.js'
import {mockAndCaptureOutput} from './testing/output.js'
Expand Down Expand Up @@ -79,6 +79,50 @@ class MockCommandWithRequiredFlagInNonTTY extends MockCommand {
}
}

class MockCommandWithDeclarativeNonTTYRequirements extends MockCommand {
/* eslint-disable @shopify/cli/command-flags-with-env */
static flags = {
...MockCommand.flags,
'first-required': requiredIfNonInteractive(
Flags.string({env: 'SHOPIFY_FLAG_TEST_FIRST_REQUIRED', description: 'The first required flag.'}),
),
'second-required': requiredIfNonInteractive(
Flags.string({env: 'SHOPIFY_FLAG_TEST_SECOND_REQUIRED', description: 'The second required flag.'}),
),
'alternative-one': Flags.string({}),
'alternative-two': Flags.string({}),
conditional: Flags.string({}),
'require-conditional': Flags.boolean({}),
}
/* eslint-enable @shopify/cli/command-flags-with-env */

static nonTTYFlagRequirements() {
return [
{flags: ['alternative-one', 'alternative-two']},
{flags: ['conditional'], when: (flags: Record<string, unknown>) => Boolean(flags['require-conditional'])},
]
}

async run(): Promise<void> {
const {flags} = await this.parse(MockCommandWithDeclarativeNonTTYRequirements)
testResult = flags
}
}

class MockCommandWithDefaultFalseNonTTYRequirement extends MockCommand {
/* eslint-disable @shopify/cli/command-flags-with-env */
static flags = {
...MockCommand.flags,
force: requiredIfNonInteractive(Flags.boolean({default: false})),
}
/* eslint-enable @shopify/cli/command-flags-with-env */

async run(): Promise<void> {
const {flags} = await this.parse(MockCommandWithDefaultFalseNonTTYRequirement)
testResult = flags
}
}

class MockCommandWithoutEnvironmentFlag extends Command {
/* eslint-disable @shopify/cli/command-flags-with-env */
static flags = {
Expand Down Expand Up @@ -446,9 +490,90 @@ describe('applying environments', async () => {
await MockCommandWithRequiredFlagInNonTTY.run(['--path', tmpDir])

// Then
expect(unstyled(testError!.message)).toMatch('Flag not specified:\n\nnonTTYRequiredFlag')
expect(unstyled(testError!.message)).toMatch('Flag not specified:\n\n--nonTTYRequiredFlag')
})

runTestInTmpDir('reports all missing declarative non-TTY requirements', async (tmpDir: string) => {
// Given
vi.stubEnv('CI', 'true')

// When
await MockCommandWithDeclarativeNonTTYRequirements.run(['--path', tmpDir])

// Then
expect(unstyled(testError!.message)).toContain(`Flags not specified:

--first-required
--second-required
--alternative-one or --alternative-two`)
})

runTestInTmpDir('accepts annotated flags supplied through environment variables', async (tmpDir: string) => {
// Given
vi.stubEnv('CI', 'true')
vi.stubEnv('SHOPIFY_FLAG_TEST_FIRST_REQUIRED', 'first')
vi.stubEnv('SHOPIFY_FLAG_TEST_SECOND_REQUIRED', 'second')

// When
await MockCommandWithDeclarativeNonTTYRequirements.run(['--path', tmpDir, '--alternative-two', 'alternative'])

// Then
expect(testError).toBeUndefined()
expect(testResult).toMatchObject({
'first-required': 'first',
'second-required': 'second',
'alternative-two': 'alternative',
})
})

runTestInTmpDir('does not treat a false boolean default as a supplied flag', async (tmpDir: string) => {
// Given
vi.stubEnv('CI', 'true')

// When
await MockCommandWithDefaultFalseNonTTYRequirement.run(['--path', tmpDir])

// Then
expect(unstyled(testError!.message)).toContain('--force')

// When
testError = undefined
await MockCommandWithDefaultFalseNonTTYRequirement.run(['--path', tmpDir, '--force'])

// Then
expect(testError).toBeUndefined()
})

runTestInTmpDir(
'applies conditional non-TTY requirements only when their condition matches',
async (tmpDir: string) => {
// Given
vi.stubEnv('CI', 'true')
const requiredFlags = [
'--path',
tmpDir,
'--first-required',
'first',
'--second-required',
'second',
'--alternative-one',
'alternative',
]

// When
await MockCommandWithDeclarativeNonTTYRequirements.run(requiredFlags)

// Then
expect(testError).toBeUndefined()

// When
await MockCommandWithDeclarativeNonTTYRequirements.run([...requiredFlags, '--require-conditional'])

// Then
expect(unstyled(testError!.message)).toContain('--conditional')
},
)

runTestInTmpDir('reports environment settings that do not match defaults', async (tmpDir: string) => {
// Given
const outputMock = mockAndCaptureOutput()
Expand Down
63 changes: 53 additions & 10 deletions packages/cli-kit/src/public/node/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ export type ArgOutput = OutputArgs<any>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type FlagOutput = OutputFlags<any>

export interface NonTTYFlagRequirement {
/** At least one of these flags must be present when the requirement applies. */
flags: string[]
/** Determines whether the requirement applies to the parsed flags. */
when?: (flags: FlagOutput) => boolean
}

interface EnvironmentFlags {
'auth-alias'?: string
environment?: string[]
Expand All @@ -26,6 +33,10 @@ interface EnvironmentFlags {
abstract class BaseCommand extends Command {
static baseFlags: FlagInput<{}> = {}

public static nonTTYFlagRequirements(_flags: FlagOutput): NonTTYFlagRequirement[] {
return []
}

// Replace markdown links to plain text like: "link label" (url)
public static descriptionWithoutMarkdown(): string | undefined {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -108,6 +119,7 @@ abstract class BaseCommand extends Command {
result = await this.resultWithEnvironment<TFlags, TGlobalFlags, TArgs>(result, options, argv)
await setCurrentSessionAlias(result.flags['auth-alias'])
await addFromParsedFlags(result.flags)
this.failMissingNonTTYFlagRequirements(result.flags, this.applicableNonTTYFlagRequirements(result.flags))
return {...result, ...{argv: result.argv as string[]}}
}

Expand All @@ -117,20 +129,51 @@ abstract class BaseCommand extends Command {
}

protected failMissingNonTTYFlags(flags: FlagOutput, requiredFlags: string[]): void {
this.failMissingNonTTYFlagRequirements(
flags,
requiredFlags.map((flag) => ({flags: [flag]})),
)
}

private failMissingNonTTYFlagRequirements(flags: FlagOutput, requirements: NonTTYFlagRequirement[]): void {
if (terminalSupportsPrompting()) return

requiredFlags.forEach((name: string) => {
if (!(name in flags)) {
throw new AbortError(
outputContent`Flag not specified:
const missingRequirements = requirements.filter((requirement) =>
requirement.flags.every((flag) => flags[flag] === undefined || flags[flag] === false),
)
if (missingRequirements.length === 0) return

${outputToken.cyan(name)}
const heading = missingRequirements.length === 1 ? 'Flag not specified' : 'Flags not specified'
const formattedRequirements = missingRequirements
.map((requirement) => requirement.flags.map((flag) => `--${flag}`).join(' or '))
.join('\n')
const explanation = missingRequirements.length === 1 ? 'This flag is required' : 'These flags are required'

This flag is required in non-interactive terminal environments, such as a CI environment, or when piping input from another process.`,
'To resolve this, specify the option in the command, or run the command in an interactive environment such as your local terminal.',
)
}
})
throw new AbortError(
outputContent`${heading}:

${outputToken.cyan(formattedRequirements)}

${explanation} in non-interactive terminal environments, such as a CI environment, or when piping input from another process.`,
'To resolve this, specify the options in the command, or run the command in an interactive environment such as your local terminal.',
)
}

private applicableNonTTYFlagRequirements(flags: FlagOutput): NonTTYFlagRequirement[] {
const command = this.constructor as unknown as {
flags?: FlagInput
baseFlags?: FlagInput
nonTTYFlagRequirements?: (flags: FlagOutput) => NonTTYFlagRequirement[]
}
const allFlags = {...command.baseFlags, ...command.flags}
const requirementsFromFlags = Object.entries(allFlags)
.filter(([, flag]) => Boolean((flag as {requiredIfNonInteractive?: boolean}).requiredIfNonInteractive))
.map(([name]) => ({flags: [name]}))
const commandRequirements = (command.nonTTYFlagRequirements?.(flags) ?? []).filter(
(requirement) => requirement.when?.(flags) ?? true,
)

return [...requirementsFromFlags, ...commandRequirements]
}

private async resultWithEnvironment<
Expand Down
21 changes: 20 additions & 1 deletion packages/cli-kit/src/public/node/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {clearCache, runCLI, runCreateCLI, portFlag} from './cli.js'
import {clearCache, runCLI, runCreateCLI, portFlag, requiredIfNonInteractive} from './cli.js'
import {findUpAndReadPackageJson} from './node-package-manager.js'
import {mockAndCaptureOutput} from './testing/output.js'
import * as confStore from '../../private/node/conf-store.js'
import {describe, expect, test, vi} from 'vitest'
import {Flags} from '@oclif/core'

vi.mock('./node-package-manager.js')

Expand Down Expand Up @@ -148,3 +149,21 @@ describe('portFlag', () => {
},
)
})

describe('requiredIfNonInteractive', () => {
test.each([
['The app template', 'The app template. Required if non interactive.'],
['The app template.', 'The app template. Required if non interactive.'],
])('annotates a copy without duplicating punctuation in %s', (description, expectedDescription) => {
const flag = Flags.string({description})

const got = requiredIfNonInteractive(flag)

expect(got).toMatchObject({
description: expectedDescription,
requiredIfNonInteractive: true,
})
expect(flag.description).toBe(description)
expect(flag).not.toHaveProperty('requiredIfNonInteractive')
})
})
19 changes: 19 additions & 0 deletions packages/cli-kit/src/public/node/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,25 @@ 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 cannot prompt for a value.
*
* The flag remains optional in interactive terminals. In non-interactive environments,
* `BaseCommand` validates the flag automatically and the requirement is shown in `--help`.
* Use `BaseCommand.nonTTYFlagRequirements` for conditional or alternative requirements.
*
* @param flag - An oclif flag definition.
* @returns A new flag definition annotated for non-interactive validation and help output.
*/
export function requiredIfNonInteractive<TFlag extends {description?: string}>(flag: TFlag): TFlag {
const existingDescription = flag.description?.trimEnd()
const punctuatedDescription =
existingDescription && !existingDescription.endsWith('.') ? `${existingDescription}.` : existingDescription
const description = [punctuatedDescription, 'Required if non interactive.'].filter(Boolean).join(' ')

return {...flag, description, requiredIfNonInteractive: true}
}

/**
* Clear the CLI cache, used to store some API responses and handle notifications status
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// https://eslint.org/docs/developer-guide/working-with-rules
const {findFlagOptions} = require('./flag-options')

const VALID_FLAGS = ['SHOPIFY_FLAG_']

module.exports = {
Expand All @@ -14,16 +16,14 @@ module.exports = {
PropertyDefinition(node) {
if (node.key.name === 'flags') {
node.value.properties.forEach((flag) => {
const arguments = flag.value?.arguments ?? []
const argument = arguments[0]
if (!argument) {
return
}
const envProperty = argument.properties.find((property) => property.key.name === 'env')?.value?.value
const options = findFlagOptions(flag.value)
if (!options) return

const envProperty = options.properties.find((property) => property.key?.name === 'env')?.value?.value
if (envProperty) {
if (!VALID_FLAGS.some((flag) => envProperty.startsWith(flag))) {
if (!VALID_FLAGS.some((validPrefix) => envProperty.startsWith(validPrefix))) {
context.report(
argument,
options,
`Flags' environment variable must start with ${new Intl.ListFormat('en', {
style: 'long',
type: 'disjunction',
Expand Down
14 changes: 7 additions & 7 deletions packages/eslint-plugin-cli/rules/command-flags-with-env.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// https://eslint.org/docs/developer-guide/working-with-rules
const {findFlagOptions} = require('./flag-options')

module.exports = {
meta: {
type: 'problem',
Expand All @@ -12,15 +14,13 @@ module.exports = {
PropertyDefinition(node) {
if (node.key.name === 'flags') {
node.value.properties.forEach((flag) => {
const arguments = flag.value?.arguments ?? []
const argument = arguments[0]
if (!argument) {
return
}
const properties = argument.properties.map((property) => property.key.name)
const options = findFlagOptions(flag.value)
if (!options) return

const properties = options.properties.map((property) => property.key?.name)
if (!properties.includes('env')) {
context.report(
argument,
options,
'Flags must specify the environment variable that represents the flag through the env property',
)
}
Expand Down
Loading
Loading