Skip to content

Commit

Permalink
Drop support for local CLI switchover (#4842)
Browse files Browse the repository at this point in the history
### WHY are these changes introduced?

Drops support for the local-mode CLI switchover. This never got full release and we've changed our approach pretty substantially since then.

Also: Refactors the CLI launching mechanism to improve testability and reduce side effects by:
- Extracting core CLI launching logic into a dedicated module
- Making environment variables and process arguments injectable
- Adding comprehensive test coverage

### WHAT is this pull request doing?

- Creates new `cli-launcher.ts` module to handle core CLI launching functionality
- Refactors `cli.ts` to use dependency injection for better testing
- Adds extensive test coverage for CLI launching and environment setup
- Removes unused code related to local CLI detection
- Makes environment variables and process arguments configurable

### How to test your changes?

1. Run the CLI normally to verify basic functionality works
2. Try launching with various flags like `--verbose` and `--no-color`

### Measuring impact

- [x] n/a - this doesn't need measurement, e.g. a linting rule or a bug-fix

### Checklist

- [x] I've considered possible cross-platform impacts (Mac, Linux, Windows)
- [x] I've considered possible documentation changes
  • Loading branch information
shauns authored Nov 14, 2024
1 parent c393d92 commit 6296c65
Show file tree
Hide file tree
Showing 6 changed files with 237 additions and 121 deletions.
24 changes: 24 additions & 0 deletions packages/cli-kit/src/public/node/cli-launcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {launchCLI} from './cli-launcher.js'
import {describe, expect, test} from 'vitest'

describe('launchCLI', () => {
test('launches the CLI', async () => {
const originalStdoutWrite = process.stdout.write
const outputs: string[] = []
process.stdout.write = (str) => {
outputs.push(str as any)
return true
}

await launchCLI({moduleURL: import.meta.url, argv: ['--help']})
expect(outputs.join('\n')).toContain(
'A set of utilities, interfaces, and models that are common across all the platform features',
)
// eslint-disable-next-line require-atomic-updates
process.stdout.write = originalStdoutWrite
})

test('fails if args are invalid', async () => {
await expect(launchCLI({moduleURL: import.meta.url, argv: ['this', 'is', 'invalid']})).rejects.toThrow()
})
})
36 changes: 36 additions & 0 deletions packages/cli-kit/src/public/node/cli-launcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {fileURLToPath} from 'node:url'

interface Options {
moduleURL: string
argv?: string[]
}

/**
* Launches the CLI through our custom OCLIF loader.
*
* @param options - Options.
* @returns A promise that resolves when the CLI has been launched.
*/
export async function launchCLI(options: Options): Promise<void> {
const {errorHandler} = await import('./error-handler.js')
const {isDevelopment} = await import('./context/local.js')
const oclif = await import('@oclif/core')
const {ShopifyConfig} = await import('./custom-oclif-loader.js')

if (isDevelopment()) {
oclif.default.settings.debug = true
}

try {
// Use a custom OCLIF config to customize the behavior of the CLI
const config = new ShopifyConfig({root: fileURLToPath(options.moduleURL)})
await config.load()

await oclif.default.run(options.argv, config)
await oclif.default.flush()
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (error) {
await errorHandler(error as Error)
return oclif.default.Errors.handle(error as Error)
}
}
114 changes: 114 additions & 0 deletions packages/cli-kit/src/public/node/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {clearCache, runCLI, runCreateCLI} from './cli.js'
import {findUpAndReadPackageJson} from './node-package-manager.js'
import {mockAndCaptureOutput} from './testing/output.js'
import {cacheClear} from '../../private/node/conf-store.js'
import {describe, expect, test, vi} from 'vitest'

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

describe('cli', () => {
test('prepares to run the CLI', async () => {
const launchCLI = vi.fn()
await runCLI({moduleURL: 'test', development: false}, launchCLI)
expect(launchCLI).toHaveBeenCalledWith({moduleURL: 'test'})
})

test('triggers no colour mode based on --no-color flag', async () => {
const launchCLI = vi.fn()
const env = {} as any
await runCLI({moduleURL: 'test', development: false}, launchCLI, ['--no-color'], env)
expect(env.FORCE_COLOR).toBe('0')
})

test('triggers no colour mode based on NO_COLOR environment variable', async () => {
const launchCLI = vi.fn()
const env = {NO_COLOR: 'TRUE'} as any
await runCLI({moduleURL: 'test', development: false}, launchCLI, [], env)
expect(env.FORCE_COLOR).toBe('0')
})

test('triggers no colour mode based on SHOPIFY_FLAG_NO_COLOR environment variable', async () => {
const launchCLI = vi.fn()
const env = {SHOPIFY_FLAG_NO_COLOR: 'TRUE'} as any
await runCLI({moduleURL: 'test', development: false}, launchCLI, [], env)
expect(env.FORCE_COLOR).toBe('0')
})

test('triggers no colour mode based on TERM environment variable', async () => {
const launchCLI = vi.fn()
const env = {TERM: 'dumb'} as any
await runCLI({moduleURL: 'test', development: false}, launchCLI, [], env)
expect(env.FORCE_COLOR).toBe('0')
})

test('triggers DEBUG based on --verbose flag', async () => {
const launchCLI = vi.fn()
const env = {} as any
await runCLI({moduleURL: 'test', development: false}, launchCLI, ['--verbose'], env)
expect(env.DEBUG).toBe('*')
})

test('triggers SHOPIFY_CLI_ENV based on running in development mode', async () => {
const launchCLI = vi.fn()
const env = {} as any
await runCLI({moduleURL: 'test', development: true}, launchCLI, [], env)
expect(env.SHOPIFY_CLI_ENV).toBe('development')
})

test('warns if running an old Node version', async () => {
const launchCLI = vi.fn()
const outputMock = mockAndCaptureOutput()
await runCLI({moduleURL: 'test', development: false}, launchCLI, [], {}, {node: '17.9'} as any)
expect(outputMock.output()).toMatchInlineSnapshot(`
"╭─ warning ────────────────────────────────────────────────────────────────────╮
│ │
│ Upgrade to a supported Node version now. │
│ │
│ Node 17 has reached end-of-life and poses security risks. When you upgrade │
│ to a supported version [1], you'll be able to use Shopify CLI without │
│ interruption. │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
[1] https://nodejs.dev/en/about/previous-releases
"
`)
})

test('changes process.argv when running create-x', async () => {
const launchCLI = vi.fn()
const argv = ['node', 'packages/create-app/bin/run.js', '--verbose']
vi.mocked(findUpAndReadPackageJson).mockResolvedValue({content: {name: '@shopify/create-app'}} as any)
await runCreateCLI({moduleURL: import.meta.url, development: false}, launchCLI, argv, {})
expect(argv).toMatchInlineSnapshot(`
[
"node",
"packages/create-app/bin/run.js",
"init",
"--verbose",
]
`)
})

test('leaves process.argv unchanged if init is already present', async () => {
const launchCLI = vi.fn()
const argv = ['node', 'packages/create-app/bin/run.js', 'init', '--verbose']
vi.mocked(findUpAndReadPackageJson).mockResolvedValue({content: {name: '@shopify/create-app'}} as any)
await runCreateCLI({moduleURL: import.meta.url, development: false}, launchCLI, argv, {})
expect(argv).toMatchInlineSnapshot(`
[
"node",
"packages/create-app/bin/run.js",
"init",
"--verbose",
]
`)
})
})

describe('clearCache', () => {
test('clears the cache', () => {
clearCache()
expect(cacheClear).toHaveBeenCalled()
})
})
160 changes: 47 additions & 113 deletions packages/cli-kit/src/public/node/cli.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {isTruthy} from './context/utilities.js'
import {cacheClear} from '../../private/node/conf-store.js'
import {Flags} from '@oclif/core'
import {fileURLToPath} from 'url'

/**
* IMPORTANT NOTE: Imports in this module are dynamic to ensure that "setupEnvironmentVariables" can dynamically
Expand All @@ -14,8 +13,8 @@ interface RunCLIOptions {
development: boolean
}

async function warnIfOldNodeVersion() {
const nodeVersion = process.versions.node
async function warnIfOldNodeVersion(versions: NodeJS.ProcessVersions = process.versions) {
const nodeVersion = versions.node
const nodeMajorVersion = Number(nodeVersion.split('.')[0])

const currentSupportedNodeVersion = 18
Expand All @@ -38,28 +37,32 @@ async function warnIfOldNodeVersion() {
}
}

function setupEnvironmentVariables(options: Pick<RunCLIOptions, 'development'>) {
function setupEnvironmentVariables(
options: Pick<RunCLIOptions, 'development'>,
argv: string[] = process.argv,
env: NodeJS.ProcessEnv = process.env,
) {
/**
* By setting DEBUG=* when --verbose is passed we are increasing the
* verbosity of oclif. Oclif uses debug (https://www.npmjs.com/package/debug)
* for logging, and it's configured through the DEBUG= environment variable.
*/
if (process.argv.includes('--verbose')) {
process.env.DEBUG = process.env.DEBUG ?? '*'
if (argv.includes('--verbose')) {
env.DEBUG = env.DEBUG ?? '*'
}
if (options.development) {
process.env.SHOPIFY_CLI_ENV = process.env.SHOPIFY_CLI_ENV ?? 'development'
env.SHOPIFY_CLI_ENV = env.SHOPIFY_CLI_ENV ?? 'development'
}
}

function forceNoColor() {
function forceNoColor(argv: string[] = process.argv, env: NodeJS.ProcessEnv = process.env) {
if (
process.argv.includes('--no-color') ||
isTruthy(process.env.NO_COLOR) ||
isTruthy(process.env.SHOPIFY_FLAG_NO_COLOR) ||
process.env.TERM === 'dumb'
argv.includes('--no-color') ||
isTruthy(env.NO_COLOR) ||
isTruthy(env.SHOPIFY_FLAG_NO_COLOR) ||
env.TERM === 'dumb'
) {
process.env.FORCE_COLOR = '0'
env.FORCE_COLOR = '0'
}
}

Expand All @@ -68,120 +71,51 @@ function forceNoColor() {
* a CLI
* @param options - Options.
*/
export async function runCLI(options: RunCLIOptions): Promise<void> {
setupEnvironmentVariables(options)
forceNoColor()
await warnIfOldNodeVersion()
/**
* These imports need to be dynamic because if they are static
* they are loaded before we set the DEBUG=* environment variable
* and therefore it has no effect.
*/
const {errorHandler} = await import('./error-handler.js')
const {isDevelopment} = await import('./context/local.js')
const oclif = await import('@oclif/core')
const {ShopifyConfig} = await import('./custom-oclif-loader.js')

if (isDevelopment()) {
oclif.default.settings.debug = true
}

try {
// Use a custom OCLIF config to customize the behavior of the CLI
const config = new ShopifyConfig({root: fileURLToPath(options.moduleURL)})
await config.load()

await oclif.default.run(undefined, config)
await oclif.default.flush()
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (error) {
await errorHandler(error as Error)
return oclif.default.Errors.handle(error as Error)
export async function runCLI(
options: RunCLIOptions & {runInCreateMode?: boolean},
launchCLI: (options: {moduleURL: string}) => Promise<void>,
argv: string[] = process.argv,
env: NodeJS.ProcessEnv = process.env,
versions: NodeJS.ProcessVersions = process.versions,
): Promise<void> {
setupEnvironmentVariables(options, argv, env)
if (options.runInCreateMode) {
await addInitToArgvWhenRunningCreateCLI(options, argv)
}
forceNoColor(argv, env)
await warnIfOldNodeVersion(versions)
return launchCLI({moduleURL: options.moduleURL})
}

/**
* A function for create-x CLIs that automatically runs the "init" command.
*/
export async function runCreateCLI(options: RunCLIOptions): Promise<void> {
setupEnvironmentVariables(options)

async function addInitToArgvWhenRunningCreateCLI(
options: Pick<RunCLIOptions, 'moduleURL'>,
argv: string[] = process.argv,
): Promise<void> {
const {findUpAndReadPackageJson} = await import('./node-package-manager.js')
const {moduleDirectory} = await import('./path.js')

const packageJson = await findUpAndReadPackageJson(moduleDirectory(options.moduleURL))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const packageName = (packageJson.content as any).name as string
const name = packageName.replace('@shopify/create-', '')
const initIndex = process.argv.findIndex((arg) => arg.includes('init'))
const initIndex = argv.findIndex((arg) => arg.includes('init'))
if (initIndex === -1) {
const initIndex =
process.argv.findIndex((arg) => arg.match(new RegExp(`bin(\\/|\\\\)+(create-${name}|dev|run)`))) + 1
process.argv.splice(initIndex, 0, 'init')
const initIndex = argv.findIndex((arg) => arg.match(new RegExp(`bin(\\/|\\\\)+(create-${name}|dev|run)`))) + 1
argv.splice(initIndex, 0, 'init')
}
await runCLI(options)
}

export async function useLocalCLIIfDetected(filepath: string): Promise<boolean> {
const {environmentVariables} = await import('../../private/node/constants.js')
const {joinPath: join} = await import('./path.js')
const {exec} = await import('./system.js')

// Temporary flag while we test out this feature and ensure it won't break anything!
if (!isTruthy(process.env[environmentVariables.enableCliRedirect])) return false

// Setting an env variable in the child process prevents accidental recursion.
if (isTruthy(process.env[environmentVariables.skipCliRedirect])) return false

// If already running via package manager, we can assume it's running correctly already.
if (process.env.npm_config_user_agent) return false

const cliPackage = await localCliPackage()
if (!cliPackage) return false

const correctExecutablePath = join(cliPackage.path, cliPackage.bin.shopify)
if (correctExecutablePath === filepath) return false
try {
await exec(correctExecutablePath, process.argv.slice(2, process.argv.length), {
stdio: 'inherit',
env: {[environmentVariables.skipCliRedirect]: '1'},
})
// eslint-disable-next-line no-catch-all/no-catch-all, @typescript-eslint/no-explicit-any
} catch (processError: any) {
process.exit(processError.exitCode)
}
return true
}

interface CliPackageInfo {
path: string
bin: {shopify: string}
}

interface PackageJSON {
dependencies?: {[packageName: string]: CliPackageInfo}
devDependencies?: {[packageName: string]: CliPackageInfo}
peerDependencies?: {[packageName: string]: CliPackageInfo}
}

export async function localCliPackage(): Promise<CliPackageInfo | undefined> {
const {captureOutput} = await import('./system.js')

let npmListOutput = ''
let localShopifyCLI: PackageJSON = {}
try {
npmListOutput = await captureOutput('npm', ['list', '@shopify/cli', '--json', '-l'])
localShopifyCLI = JSON.parse(npmListOutput)
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (err) {
return
}
const dependenciesList = {
...localShopifyCLI.peerDependencies,
...localShopifyCLI.devDependencies,
...localShopifyCLI.dependencies,
}
return dependenciesList['@shopify/cli']
/**
* A function for create-x CLIs that automatically runs the "init" command.
*/
export async function runCreateCLI(
options: RunCLIOptions,
launchCLI: (options: {moduleURL: string}) => Promise<void>,
argv: string[] = process.argv,
env: NodeJS.ProcessEnv = process.env,
versions: NodeJS.ProcessVersions = process.versions,
): Promise<void> {
return runCLI({...options, runInCreateMode: true}, launchCLI, argv, env, versions)
}

/**
Expand Down
Loading

0 comments on commit 6296c65

Please sign in to comment.