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
450 changes: 265 additions & 185 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
"/oclif.manifest.json"
],
"dependencies": {
"@contentstack/cli-command": "^1.8.7",
"@contentstack/cli-utilities": "^1.18.5",
"@contentstack/cli-command": "^2.0.2",
"@contentstack/cli-utilities": "^2.0.2",
"@oclif/core": "^4.2.7",
"@oclif/plugin-help": "^6.2.25",
"@rollup/plugin-commonjs": "^28.0.9",
Expand Down
17 changes: 17 additions & 0 deletions src/core/failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,23 @@ describe('reportFailure', () => {
await expect(outcome).resolves.toBe(exit);
expect(lines).toEqual([]);
});

it('lets an exit request raised by another copy of oclif pass through untouched, as csdx 2 hands its own to plugins', async () => {
const exit = Object.assign(new Error('EEXIT: 3'), { code: 'EEXIT', oclif: { exit: 3 } });
const { lines, outcome } = report(exit);

expect(exit).not.toBeInstanceOf(Errors.ExitError);
await expect(outcome).resolves.toBe(exit);
expect(lines).toEqual([]);
});

it('keeps the exit code of an oclif error raised by another copy of oclif', async () => {
const { lines, outcome } = report(Object.assign(new Error('Unexpected argument: extra'), { oclif: { exit: 2 } }));
const thrown = await outcome;

expect(lines).toEqual(['error: Unexpected argument: extra']);
expect(thrown.oclif.exit).toBe(2);
});
});

describe('handleStandaloneFailure', () => {
Expand Down
14 changes: 12 additions & 2 deletions src/core/failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,22 @@ function alreadyReported(err: Error): boolean {
return (err as Error & { skipOclifErrorHandling?: boolean }).skipOclifErrorHandling === true;
}

// csdx 2 makes plugins use its own @oclif/core through cli-utilities, so errors its Command throws are not
// instances of the Errors class imported here. Recognise them by the shape every oclif error carries.
export function isOclifError(err: unknown): err is Errors.CLIError {
return err instanceof Error && typeof (err as { oclif?: unknown }).oclif === 'object';
}

function isExitRequest(err: Error): boolean {
return isOclifError(err) && (err as { code?: unknown }).code === 'EEXIT';
}

export async function reportFailure(err: Error, output: FailureOutput): Promise<unknown> {
if (err instanceof Errors.ExitError || alreadyReported(err)) {
if (isExitRequest(err) || alreadyReported(err)) {
throw err;
}

if (err instanceof Errors.CLIError) {
if (isOclifError(err)) {
return failWith(err.message, err.oclif.exit, output);
}

Expand Down
7 changes: 3 additions & 4 deletions src/core/launch-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { FlagKey } from '../resources';
import { LaunchCommand, projectConfigLoader, resolveLaunchContext } from './launch-command';
import * as serviceContext from './service-context';
import type { SearchListClass } from './search-list';
import { utilitiesLoader } from './search-list';
import { promptLibrary, utilitiesLoader } from './search-list';
import { pretendTerminal, stdinReportingTTY, stdoutReportingTTY } from '../../test/support/terminal';

class Probe extends LaunchCommand {
Expand Down Expand Up @@ -336,8 +336,7 @@ describe('LaunchCommand.init', () => {
});

it('registers the searchable picker on the inquirer cliux prompts with before anything can prompt', async () => {
const load = utilitiesLoader();
const prompts = (load('inquirer') as { prompt: { prompts: Record<string, unknown> } }).prompt.prompts;
const prompts = promptLibrary().prompt.prompts;
delete prompts['search-list'];
const instance = probe();
const authSpy = jest.spyOn(authHandler, 'isAuthenticated').mockReturnValue(true);
Expand All @@ -352,7 +351,7 @@ describe('LaunchCommand.init', () => {
await instance.init();

expect((registeredWhenParsed as SearchListClass).prototype).toBeInstanceOf(
load('inquirer-search-list') as SearchListClass,
utilitiesLoader()('inquirer-search-list') as SearchListClass,
);
expect(prompts['search-list']).toBe(registeredWhenParsed);
authSpy.mockRestore();
Expand Down
65 changes: 23 additions & 42 deletions src/core/search-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import {
MORE_CHOICES_HINT,
UnlistedAnswer,
answerFor,
launchPaginated,
launchSearchList,
promptLibrary,
refuseUnlisted,
registerSearchList,
pageScroller,
Expand Down Expand Up @@ -265,33 +265,6 @@ describe('pageScroller', () => {
});
});

describe('launchPaginated', () => {
interface Paginated {
paginator: { paginate(output: string, active: number, pageSize?: number): string };
}

class Upstream {
paginator = { paginate: (output: string) => output };
}

it('pages with its own scroller, which keeps the arrow moving and says how to see more', () => {
const Prompt = launchPaginated(Upstream as never) as unknown as new () => Paginated;
const prompt = new Prompt();
const lines = Array.from({ length: 10 }, (_, index) => `c${index}`).join('\n');

expect(prompt.paginator.paginate(lines, 3, 7).split('\n')).toEqual([
'c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', `\u001b[2m${MORE_CHOICES_HINT}\u001b[22m`,
]);
expect(MORE_CHOICES_HINT).toBe('(Use ↑/↓ to see more options)');
});

it('gives each prompt a scroller of its own', () => {
const Prompt = launchPaginated(Upstream as never) as unknown as new () => Paginated;

expect(new Prompt().paginator).not.toBe(new Prompt().paginator);
});
});

describe('answerFor', () => {
it('answers with the value of the choice whose name was typed in full', () => {
expect(answerFor(CHOICES, 'NextJs')).toBe('NextJs');
Expand Down Expand Up @@ -343,35 +316,43 @@ describe('refuseUnlisted', () => {
});
});

describe('promptLibrary', () => {
it('is the default export of the inquirer cli-utilities depends on, where inquirer 12 keeps its registry', () => {
const library = promptLibrary();

expect(library).toBe((load('inquirer') as { default: unknown }).default);
expect(typeof library.registerPrompt).toBe('function');
expect(Object.keys(library.prompt.prompts)).toEqual(expect.arrayContaining(['list', 'checkbox', 'confirm', 'input']));
});
});

describe('registerSearchList', () => {
it('registers the launch search list under search-list on the inquirer that cliux prompts with', () => {
it('registers the launch search list under search-list on the inquirer that cliux prompts with, and nothing else', () => {
const registered: [string, unknown][] = [];
const loaded: string[] = [];
const fakeLoad = (id: string): unknown => {
loaded.push(id);
return id === 'inquirer' ? { registerPrompt: (name: string, prompt: unknown) => registered.push([name, prompt]) } : SearchList;
return id === 'inquirer'
? { default: { registerPrompt: (name: string, prompt: unknown) => registered.push([name, prompt]) } }
: SearchList;
};

registerSearchList(fakeLoad);

expect(loaded).toEqual(['inquirer', 'inquirer-search-list', 'inquirer/lib/prompts/list', 'inquirer/lib/prompts/checkbox']);
expect(registered.map(([name]) => name)).toEqual(['search-list', 'list', 'checkbox']);
expect(registered[0][0]).toBe('search-list');
expect(loaded).toEqual(['inquirer', 'inquirer-search-list']);
expect(registered.map(([name]) => name)).toEqual(['search-list']);
expect((registered[0][1] as SearchListClass).prototype).toBeInstanceOf(SearchList);
expect(registered[0][1]).not.toBe(SearchList);
});

it('registers on the real inquirer cli-utilities depends on when no loader is given', () => {
const inquirer = load('inquirer') as { prompt: { prompts: Record<string, unknown> } };
it('registers on the real inquirer cli-utilities depends on when no loader is given, leaving its list and checkbox as they are', () => {
const library = promptLibrary();
const { list, checkbox } = library.prompt.prompts;

registerSearchList();

expect((inquirer.prompt.prompts['search-list'] as SearchListClass).prototype).toBeInstanceOf(SearchList);
for (const type of ['list', 'checkbox']) {
const Upstream = load(`inquirer/lib/prompts/${type}`) as SearchListClass;

expect((inquirer.prompt.prompts[type] as SearchListClass).prototype).toBeInstanceOf(Upstream);
expect(inquirer.prompt.prompts[type]).not.toBe(Upstream);
}
expect((library.prompt.prompts['search-list'] as SearchListClass).prototype).toBeInstanceOf(SearchList);
expect(library.prompt.prompts.list).toBe(list);
expect(library.prompt.prompts.checkbox).toBe(checkbox);
});
});
28 changes: 9 additions & 19 deletions src/core/search-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ export type SearchListClass = new (...params: never[]) => SearchListPrompt;

export type ModuleLoader = (id: string) => unknown;

interface PromptRegistry {
export interface PromptRegistry {
prompt: { prompts: Record<string, unknown> };
registerPrompt(name: string, prompt: unknown): void;
restoreDefaultPrompts(): void;
}

export class UnlistedAnswer {
Expand Down Expand Up @@ -62,8 +64,6 @@ interface Paginator {
paginate(output: string, active: number, pageSize?: number): string;
}

export type PaginatedClass = new (...params: never[]) => { paginator: Paginator };

export function pageScroller(): Paginator {
let top = 0;

Expand All @@ -88,15 +88,6 @@ export function pageScroller(): Paginator {
};
}

export function launchPaginated(Prompt: PaginatedClass): PaginatedClass {
return class LaunchPaginated extends Prompt {
constructor(...params: never[]) {
super(...params);
this.paginator = pageScroller();
}
};
}

export function launchSearchList(SearchList: SearchListClass): SearchListClass {
return class LaunchSearchList extends SearchList {
constructor(...params: never[]) {
Expand Down Expand Up @@ -153,12 +144,11 @@ export function utilitiesLoader(): ModuleLoader {
return createRequire(require.resolve('@contentstack/cli-utilities'));
}

export function registerSearchList(load: ModuleLoader = utilitiesLoader()): void {
const inquirer = load('inquirer') as PromptRegistry;

inquirer.registerPrompt('search-list', launchSearchList(load('inquirer-search-list') as SearchListClass));
// cli-utilities 2 ships inquirer 12, which exposes the prompt registry cliux prompts with as its default export.
export function promptLibrary(load: ModuleLoader = utilitiesLoader()): PromptRegistry {
return (load('inquirer') as { default: PromptRegistry }).default;
}

for (const type of ['list', 'checkbox']) {
inquirer.registerPrompt(type, launchPaginated(load(`inquirer/lib/prompts/${type}`) as PaginatedClass));
}
export function registerSearchList(load: ModuleLoader = utilitiesLoader()): void {
promptLibrary(load).registerPrompt('search-list', launchSearchList(load('inquirer-search-list') as SearchListClass));
}
11 changes: 11 additions & 0 deletions src/secrets/secret.rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,17 @@ describe('asSafeArgvFailure', () => {
expect(asSafeArgvFailure('text')).toBe('text');
});

it('replaces a parse error raised by another copy of oclif, as csdx 2 hands its own to plugins', () => {
const raw = Object.assign(new Error(`Unexpected argument: ${secret}`), { oclif: { exit: 2 }, parse: {} });

const safe = asSafeArgvFailure(raw) as UsageError;

expect(raw).not.toBeInstanceOf(Errors.CLIError);
expect(safe).toBeInstanceOf(UsageError);
expect(safe.message).toBe(INVALID_ARGUMENTS_MESSAGE);
expect(safe.message).not.toContain(secret);
});

it('words the replacement with the message it is given, never an argv token', async () => {
const raw = await realParseFailure([`--value=${secret}`]);

Expand Down
5 changes: 2 additions & 3 deletions src/secrets/secret.rules.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Errors } from '@oclif/core';

import { UsageError } from '../core/errors';
import { isOclifError } from '../core/failure';

export const CLIENT_EXPOSED_PREFIXES = ['NEXT_PUBLIC_', 'NUXT_PUBLIC_', 'VITE_', 'GATSBY_', 'PUBLIC_'] as const;

Expand Down Expand Up @@ -50,7 +49,7 @@ export function singleKeyError(count: number): UsageError {
}

export function asSafeArgvFailure(error: unknown, message: string = INVALID_ARGUMENTS_MESSAGE): unknown {
const isParseFailure = error instanceof Errors.CLIError && Object.prototype.hasOwnProperty.call(error, 'parse');
const isParseFailure = isOclifError(error) && Object.prototype.hasOwnProperty.call(error, 'parse');

return isParseFailure ? new UsageError(message) : error;
}
27 changes: 7 additions & 20 deletions test/guards/prompt-types.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import { authHandler } from '@contentstack/cli-utilities';

import { LaunchCommand } from '../../src/core/launch-command';
import { utilitiesLoader } from '../../src/core/search-list';
import { promptLibrary } from '../../src/core/search-list';
import type { SourceFile } from '../support/sources';
import { balancedCall, productionSources } from '../support/sources';

interface PromptRegistry {
prompt: { prompts: Record<string, unknown> };
restoreDefaultPrompts(): void;
}

interface PromptCall {
site: string;
type: string | undefined;
Expand Down Expand Up @@ -43,30 +38,22 @@ function promptCallsInSrc(): PromptCall[] {
}

async function registeredAfterAFreshLaunchCommandInit(): Promise<string[]> {
const registry = utilitiesLoader()('inquirer') as PromptRegistry;
const prompts = registry.prompt.prompts;
const saved = { ...prompts };
const registry = promptLibrary();
const original = registry.prompt.prompts;
const instance = new FreshCommand([], {} as never);
jest.spyOn(authHandler, 'isAuthenticated').mockReturnValue(true);
Object.defineProperty(instance, 'launchRegion', { value: { launchHubUrl: 'https://launch-api.test' } });
Object.defineProperty(instance, 'config', { value: { userAgent: 'cli/2.0.0' } });
(instance as unknown as { parse: () => Promise<unknown> }).parse = async () => ({ flags: {} });

for (const name of Object.keys(prompts)) {
delete prompts[name];
}

// inquirer 12 swaps in a fresh registry object rather than resetting the one it had.
registry.restoreDefaultPrompts();

try {
await instance.init();
return Object.keys(prompts);
return Object.keys(registry.prompt.prompts);
} finally {
for (const name of Object.keys(prompts)) {
delete prompts[name];
}

Object.assign(prompts, saved);
registry.prompt.prompts = original;
}
}

Expand Down Expand Up @@ -101,7 +88,7 @@ describe('guard: every prompt type the CLI asks for is registered with the real
});

it('starts the registration check from inquirer defaults, so a registration left behind by another test cannot satisfy it', async () => {
const registry = utilitiesLoader()('inquirer') as PromptRegistry;
const registry = promptLibrary();
const before = Object.keys(registry.prompt.prompts);

const registered = await registeredAfterAFreshLaunchCommandInit();
Expand Down
Loading