Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/no-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/cli-kit': minor
'@shopify/cli': minor
---

Add `--no-input` to commands with global flags to disable interactive prompts and browser authentication.
792 changes: 720 additions & 72 deletions docs-shopify.dev/generated/generated_docs_data_v2.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,7 @@ import {err, ok} from '../../../public/node/result.js'
import {AbortError, BugError} from '../../../public/node/error.js'
import {isCI, openURL, sleep} from '../../../public/node/system.js'
import * as output from '../../../public/node/output.js'

import {beforeEach, describe, expect, test, vi} from 'vitest'
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
import {Response} from 'node-fetch'

vi.mock('../../../public/node/context/fqdn.js')
Expand All @@ -27,11 +26,14 @@ vi.mock('./exchange.js')
vi.mock('../../../public/node/system.js')

beforeEach(() => {
vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', '')
vi.mocked(isTTY).mockReturnValue(true)
vi.mocked(isCI).mockReturnValue(false)
vi.mocked(openURL).mockResolvedValue(true)
})

afterEach(() => vi.unstubAllEnvs())

describe('requestDeviceAuthorization', () => {
const data: any = {
device_code: 'device_code',
Expand Down Expand Up @@ -162,6 +164,19 @@ describe('requestDeviceAuthorization', () => {
expect(outputInfo).not.toHaveBeenCalledWith('👉 Press any key to open the login page on your browser')
})

test('does not open the browser when input is disabled', async () => {
const response = new Response(JSON.stringify(data))
vi.mocked(shopifyFetch).mockResolvedValue(response)
vi.mocked(identityFqdn).mockResolvedValue('fqdn.com')
vi.mocked(clientId).mockReturnValue('clientId')
vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', 'true')

await expect(requestDeviceAuthorization(['scope1', 'scope2'])).rejects.toThrow(
'Authorization is required to continue, but the current environment does not support interactive prompts.',
)
expect(openURL).not.toHaveBeenCalled()
})

test('when the response is not valid JSON, throw an error with context without retrying', async () => {
// Given
const response = new Response('not valid JSON')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {isGatewayErrorStatus} from '../api/status-checks.js'
import {identityFqdn} from '../../../public/node/context/fqdn.js'
import {recordRetry} from '../../../public/node/analytics.js'
import {shopifyFetch} from '../../../public/node/http.js'
import {isInputDisabled} from '../../../public/node/no-input.js'
import {outputContent, outputDebug, outputInfo, outputToken} from '../../../public/node/output.js'
import {AbortError, BugError} from '../../../public/node/error.js'
import {isCI, openURL, sleep} from '../../../public/node/system.js'
Expand Down Expand Up @@ -64,7 +65,7 @@ export async function requestDeviceAuthorization(scopes: string[]): Promise<Devi

outputInfo('\nTo run this command, log in to Shopify.')

if (isCI()) {
if (isInputDisabled() || isCI()) {
throw new AbortError(
'Authorization is required to continue, but the current environment does not support interactive prompts.',
'To resolve this, specify credentials in your environment, or run the command in an interactive environment such as your local terminal.',
Expand Down
35 changes: 34 additions & 1 deletion packages/cli-kit/src/public/node/base-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,6 @@ describe('applying environments', async () => {
test(testName, async () => {
testResult = {}
testError = undefined

await inTemporaryDirectory(async (tmpDir) => {
await writeFile(joinPath(tmpDir, 'shopify.environments.toml'), encodeTOML(allEnvironments as any))
await testFunc(tmpDir)
Expand Down Expand Up @@ -603,6 +602,32 @@ describe('applying environments', async () => {
},
)

runTestInTmpDir('provides --no-input to commands through the global flags', async (tmpDir: string) => {
// When
await MockCommand.run(['--path', tmpDir, '--no-input'])

// Then
expect(testError).toBeUndefined()
expect(testResult['no-input']).toBe(true)
})

runTestInTmpDir('treats disabled input as non-interactive in a TTY', async (tmpDir: string) => {
// When
vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', 'true')
await MockCommandWithRequiredFlagInNonTTY.run(['--path', tmpDir, '--no-input'])

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

runTestInTmpDir('supports disabling input through the environment', async (tmpDir: string) => {
vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', 'true')

await MockCommandWithRequiredFlagInNonTTY.run(['--path', tmpDir])

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

runTestInTmpDir('does not throw in TTY mode when a non-TTY required argument is missing', async (tmpDir: string) => {
// Given — simulate interactive terminal
Object.defineProperty(process.stdin, 'isTTY', {value: true, configurable: true, writable: true})
Expand All @@ -627,6 +652,14 @@ describe('applying environments', async () => {
expect(unstyled(testError!.message)).toMatch('Flag not specified:\n\n--nonTTYRequiredFlag')
})

runTestInTmpDir('treats piped stdin as non-interactive', async (tmpDir: string) => {
Object.defineProperty(process.stdin, 'isTTY', {value: false, configurable: true, writable: true})

await MockCommandWithRequiredFlagInNonTTY.run(['--path', tmpDir])

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')
Expand Down
2 changes: 2 additions & 0 deletions packages/cli-kit/src/public/node/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {isTruthy} from './context/utilities.js'
import {noInputFlag} from './no-input.js'
import {launchCLI as defaultLaunchCli} from './cli-launcher.js'
import {environmentVariables} from '../../private/node/constants.js'
import {Flags} from '@oclif/core'
Expand Down Expand Up @@ -131,6 +132,7 @@ export async function runCreateCLI(
* are shared across all the commands.
*/
export const globalFlags = {
...noInputFlag,
'no-color': Flags.boolean({
hidden: false,
description: 'Disable color output.',
Expand Down
11 changes: 10 additions & 1 deletion packages/cli-kit/src/public/node/context/local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import {
} from './local.js'
import {fileExists} from '../fs.js'
import {exec} from '../system.js'

import {afterEach, expect, describe, vi, test} from 'vitest'
import * as os from 'os'
import type {NetworkInterfaceInfo} from 'os'
Expand All @@ -34,6 +33,7 @@ describe('isTerminalInteractive', () => {
const originalEnv = {...process.env}

afterEach(() => {
vi.unstubAllEnvs()
process.stdout.isTTY = originalIsTTY
process.env.TERM = originalEnv.TERM
if (originalEnv.CI === undefined) {
Expand Down Expand Up @@ -77,6 +77,15 @@ describe('isTerminalInteractive', () => {
process.env.TERM = 'xterm-256color'
expect(isTerminalInteractive()).toBe(false)
})

test('returns false when user input is disabled', () => {
process.stdout.isTTY = true
delete process.env.CI
process.env.TERM = 'xterm-256color'
vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', 'true')

expect(isTerminalInteractive()).toBe(false)
})
})

describe('isUnitTest', () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/cli-kit/src/public/node/context/local.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {isTruthy} from './utilities.js'
import {isInputDisabled} from '../no-input.js'
import {getCIMetadata, isSet, Metadata} from '../../../private/node/context/utilities.js'
import {defaultThemeKitAccessDomain, environmentVariables, pathConstants} from '../../../private/node/constants.js'
import {randomUUID} from 'crypto'
Expand All @@ -22,7 +23,7 @@ async function lazyExec(command: string, args: string[]): Promise<void> {
* @returns True if the terminal is interactive.
*/
export function isTerminalInteractive(): boolean {
return Boolean(process.stdout.isTTY && process.env.TERM !== 'dumb' && !('CI' in process.env))
return Boolean(!isInputDisabled() && process.stdout.isTTY && process.env.TERM !== 'dumb' && !('CI' in process.env))
}

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/cli-kit/src/public/node/error-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ vi.mock('./context/local.js')
vi.mock('../../public/node/crypto.js')
vi.mock('../../private/node/context/service.js')
vi.mock('../../private/node/session.js')
vi.mock('@oclif/core', () => ({
vi.mock('@oclif/core', async (importOriginal) => ({
...(await importOriginal<typeof import('@oclif/core')>()),
settings: {
debug: false,
},
Expand Down
16 changes: 16 additions & 0 deletions packages/cli-kit/src/public/node/no-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {isInputDisabled} from './no-input.js'
import {describe, expect, test} from 'vitest'

describe('isInputDisabled', () => {
test('returns true when --no-input is present', () => {
expect(isInputDisabled(['node', 'shopify', '--no-input'], {})).toBe(true)
})

test('returns true when SHOPIFY_FLAG_NO_INPUT is enabled', () => {
expect(isInputDisabled([], {SHOPIFY_FLAG_NO_INPUT: 'true'})).toBe(true)
})

test('returns false when input is not disabled', () => {
expect(isInputDisabled([], {})).toBe(false)
})
})
20 changes: 20 additions & 0 deletions packages/cli-kit/src/public/node/no-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {isTruthy} from './context/utilities.js'
import {Flags} from '@oclif/core'

export const noInputFlag = {
'no-input': Flags.boolean({
description: 'Disable interactive prompts and browser authentication.',
env: 'SHOPIFY_FLAG_NO_INPUT',
}),
}

/**
* Returns whether the current process explicitly disabled user input.
*
* @param argv - Process arguments to inspect.
* @param environment - Environment variables to inspect.
* @returns True when `--no-input` is active.
*/
export function isInputDisabled(argv: string[] = process.argv, environment: NodeJS.ProcessEnv = process.env): boolean {
return argv.includes('--no-input') || isTruthy(environment.SHOPIFY_FLAG_NO_INPUT)
}
40 changes: 35 additions & 5 deletions packages/cli-kit/src/public/node/system.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as system from './system.js'
import {execa} from 'execa'
import open from 'open'
import {describe, expect, test, vi} from 'vitest'
import which from 'which'
import {Readable} from 'stream'
Expand All @@ -8,6 +9,7 @@ import * as fs from 'fs'

vi.mock('which')
vi.mock('execa')
vi.mock('open')
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof fs>()
return {
Expand All @@ -16,6 +18,29 @@ vi.mock('fs', async (importOriginal) => {
}
})

test('terminalSupportsPrompting returns false when input is disabled', () => {
vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', 'true')

try {
expect(system.terminalSupportsPrompting()).toBe(false)
} finally {
vi.unstubAllEnvs()
}
})

test('openURL does not launch a browser when input is disabled', async () => {
vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', 'true')

try {
await expect(system.openURL('https://shopify.dev')).rejects.toThrow(
'Opening a browser is unavailable when user input is disabled.',
)
expect(open).not.toHaveBeenCalled()
} finally {
vi.unstubAllEnvs()
}
})

describe('captureOutput', () => {
test('runs the command when it is not found in the current directory', async () => {
// Given
Expand Down Expand Up @@ -414,17 +439,22 @@ describe('readStdinString', () => {
expect(got).toBeUndefined()
})

test('returns trimmed content when stdin is piped', async () => {
test('reads piped content when interactive input is disabled', async () => {
// Given
vi.mocked(fs.fstatSync).mockReturnValue({isFIFO: () => true, isFile: () => false} as fs.Stats)
const mockStdin = Readable.from([' hello world '])
vi.spyOn(process, 'stdin', 'get').mockReturnValue(mockStdin as unknown as typeof process.stdin)
vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', 'true')

// When
const got = await system.readStdinString()
try {
// When
const got = await system.readStdinString()

// Then
expect(got).toBe('hello world')
// Then
expect(got).toBe('hello world')
} finally {
vi.unstubAllEnvs()
}
})

test('throws AbortError when stdin content exceeds the limit', async () => {
Expand Down
9 changes: 8 additions & 1 deletion packages/cli-kit/src/public/node/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {renderWarning} from './ui.js'
import {platformAndArch} from './os.js'
import {shouldDisplayColors, outputDebug} from './output.js'
import {isCloudEnvironment} from './context/local.js'
import {isInputDisabled} from './no-input.js'
import {execa, ExecaChildProcess} from 'execa'
import supportsHyperlinks from 'supports-hyperlinks'
import which from 'which'
Expand Down Expand Up @@ -54,6 +55,12 @@ interface BuildExecOptions {
* @returns A promise that resolves true if the URL was opened successfully, false otherwise.
*/
export async function openURL(url: string): Promise<boolean> {
if (isInputDisabled()) {
throw new AbortError(
'Opening a browser is unavailable when user input is disabled.',
'Remove `--no-input` and run the command in an interactive terminal.',
)
}
if (isCloudEnvironment()) return false

const externalOpen = await import('open')
Expand Down Expand Up @@ -340,7 +347,7 @@ export function terminalSupportsHyperlinks(): boolean {
* @returns True if the standard input and output streams support prompting.
*/
export function terminalSupportsPrompting(): boolean {
if (isTruthy(process.env.CI)) {
if (isInputDisabled() || isTruthy(process.env.CI)) {
return false
}
return Boolean(process.stdin.isTTY && process.stdout.isTTY)
Expand Down
Loading
Loading