diff --git a/src/infrastructure/application/cli/cli.ts b/src/infrastructure/application/cli/cli.ts index 2696acde..6b83e4d6 100644 --- a/src/infrastructure/application/cli/cli.ts +++ b/src/infrastructure/application/cli/cli.ts @@ -487,13 +487,13 @@ export class Cli { public static fromDefaults(configuration: Options): Cli { const appPaths = XDGAppPaths('com.croct.cli'); - const process = new NodeProcess(); + const process = configuration.process ?? new NodeProcess(); return new Cli({ program: configuration.program ?? ((): never => { throw new HelpfulError('CLI is running in standalone mode.'); }), - process: configuration.process ?? process, + process: process, quiet: configuration.quiet ?? false, debug: configuration.debug ?? false, stateless: configuration.stateless ?? ci.isCI, diff --git a/src/infrastructure/application/cli/program.ts b/src/infrastructure/application/cli/program.ts index 7729a80a..dad689ed 100644 --- a/src/infrastructure/application/cli/program.ts +++ b/src/infrastructure/application/cli/program.ts @@ -4,9 +4,12 @@ import {realpathSync} from 'fs'; import {ApiKey} from '@croct/sdk/apiKey'; import {Token} from '@croct/sdk/token'; import {Cli} from '@/infrastructure/application/cli/cli'; +import {NodeProcess} from '@/infrastructure/application/system/nodeProcess'; +import type {Process} from '@/application/system/process/process'; import type {Resource} from '@/application/cli/command/init'; import type {OptionMap} from '@/application/template/template'; import {ApiKeyPermission, ApplicationEnvironment} from '@/application/model/application'; +import {HasEnvVar} from '@/application/predicate/hasEnvVar'; import packageJson from '@/../package.json'; type Configuration = { @@ -438,17 +441,28 @@ function isDeepLinkCommand(args: string[]): boolean { return args.length >= 2 && ['enable', 'disable'].includes(args[0]) && args[1] === 'deep-link'; } -export async function run(args: string[] = process.argv, welcome = true): Promise { - const invocation = createProgram({interactive: true}).parse(args); +export async function run( + args: string[] = process.argv, + welcome = true, + runtime: Process = new NodeProcess(), +): Promise { + const lifecycleScript = new HasEnvVar({ + process: runtime, + variable: 'npm_lifecycle_event', + value: /^(?:pre|post)/, + }).test(); + const invocation = createProgram({interactive: !lifecycleScript}).parse(args); const options = invocation.opts(); + const interactive = options.interaction && !lifecycleScript; const cli = Cli.fromDefaults({ - program: params => run(invocation.args.slice(0, 2).concat(params)), + process: runtime, + program: params => run(invocation.args.slice(0, 2).concat(params), true, runtime), version: packageJson.version, quiet: options.quiet, debug: options.debug, - interactive: options.interaction ? undefined : false, + interactive: interactive ? undefined : false, stateless: options.stateless, apiKey: options.apiKey, token: options.token, @@ -467,7 +481,7 @@ export async function run(args: string[] = process.argv, welcome = true): Promis const program = createProgram({ cli: cli, - interactive: options.interaction, + interactive: interactive, template: templateOptions, }); diff --git a/test/infrastructure/application/cli/program.test.ts b/test/infrastructure/application/cli/program.test.ts new file mode 100644 index 00000000..029dcc11 --- /dev/null +++ b/test/infrastructure/application/cli/program.test.ts @@ -0,0 +1,98 @@ +import type {Process} from '@/application/system/process/process'; +import {Cli} from '@/infrastructure/application/cli/cli'; +import {run} from '@/infrastructure/application/cli/program'; + +jest.mock('clipboardy', () => ({})); +jest.mock('is-installed-globally', () => false); +jest.mock('is-plain-obj', () => jest.fn()); +jest.mock('strip-ansi', () => (value: string): string => value); + +jest.mock( + '@/infrastructure/application/cli/io/browserLinkOpener', + () => ({ + BrowserLinkOpener: class {}, + }), +); + +jest.mock( + '@/infrastructure/application/cli/io/boxenFormatter', + () => ({ + BoxenFormatter: class {}, + }), +); + +jest.mock( + '@/infrastructure/application/cli/io/formatting', + () => ({ + colors: {}, + format: jest.fn(), + }), +); + +jest.mock( + '@/infrastructure/application/cli/io/interactiveTaskMonitor', + () => ({ + InteractiveTaskMonitor: class {}, + }), +); + +jest.mock( + '@/infrastructure/application/evaluation/jsepExpressionEvaluator', + () => ({ + JsepExpressionEvaluator: class {}, + }), +); + +describe('CLI program', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it.each([ + {event: 'preinstall', expected: false}, + {event: 'prepare', expected: false}, + {event: 'postinstall', expected: false}, + {event: 'postbuild', expected: false}, + {event: null, expected: undefined}, + {event: '', expected: undefined}, + {event: 'install', expected: undefined}, + {event: 'build', expected: undefined}, + ])('should configure interaction for lifecycle event $event', async ({event, expected}) => { + const factory = jest.spyOn(Cli, 'fromDefaults').mockReturnValue({logout: jest.fn()} as unknown as Cli); + const runtime = { + getEnvValue: (): string | null => event, + } as Partial as Process; + + await run(['node', 'croct', 'logout'], false, runtime); + + expect(factory).toHaveBeenCalledWith(expect.objectContaining({ + process: runtime, + interactive: expected, + })); + }); + + it('should preserve the explicit non-interactive override', async () => { + const factory = jest.spyOn(Cli, 'fromDefaults').mockReturnValue({logout: jest.fn()} as unknown as Cli); + const runtime = { + getEnvValue: (): null => null, + } as Partial as Process; + + await run(['node', 'croct', '--no-interaction', 'logout'], false, runtime); + + expect(factory).toHaveBeenCalledWith(expect.objectContaining({ + process: runtime, + interactive: false, + })); + }); + + it('should resolve the default directory from the injected process', () => { + const getCurrentDirectory = jest.fn(() => '/sentinel/project'); + const runtime = { + getCurrentDirectory: getCurrentDirectory, + } as Partial as Process; + + Cli.fromDefaults({process: runtime}); + + expect(getCurrentDirectory).toHaveBeenCalledTimes(1); + }); +});