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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
"@backstage/cli": "0.36.3",
"@backstage/cli-common": "0.2.2",
"@backstage/cli-defaults": "0.1.3",
"@backstage/cli-module-actions": "0.1.3",
"@backstage/cli-module-auth": "0.1.4",
"@backstage/cli-module-build": "0.1.4",
"@backstage/cli-module-config": "0.1.3",
"@backstage/cli-node": "0.3.3",
Expand Down
29 changes: 28 additions & 1 deletion src/commands/intent-based-actions/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ describe('execPassthrough', () => {
expect(mockSpawn).toHaveBeenCalledTimes(1);
const [command, args] = mockSpawn.mock.calls[0];
expect(command).toBe(process.execPath);
expect(args[0]).toContain('@backstage/cli-module-auth');
expect(args).toEqual(
expect.arrayContaining([
'auth',
Expand All @@ -59,6 +60,18 @@ describe('execPassthrough', () => {
);
});

it('uses the dedicated actions CLI module to avoid project module discovery', () => {
const child = createFakeChild();
mockSpawn.mockReturnValue(child as unknown as ReturnType<typeof spawn>);

execPassthrough(['actions', 'sources', 'list']);

const [command, args] = mockSpawn.mock.calls[0];
expect(command).toBe(process.execPath);
expect(args[0]).toContain('@backstage/cli-module-actions');
expect(args.slice(1)).toEqual(['actions', 'sources', 'list']);
});

it('rebrands "backstage-cli" as "rhdh-cli" in streamed stdout and exits with the child code', () => {
const child = createFakeChild();
mockSpawn.mockReturnValue(child as unknown as ReturnType<typeof spawn>);
Expand All @@ -76,6 +89,20 @@ describe('execPassthrough', () => {
expect(exitSpy).toHaveBeenCalledWith(0);
});

it('rebrands a CLI module name split across output chunks', () => {
const child = createFakeChild();
mockSpawn.mockReturnValue(child as unknown as ReturnType<typeof spawn>);

execPassthrough(['actions', 'sources', 'list']);
child.stdout.emit('data', Buffer.from('@backstage/cli-module-'));
child.stdout.emit('data', Buffer.from('actions v0.1.3\n'));
child.emit('close', 0);

const written = stdoutSpy.mock.calls.map(call => call[0]).join('');
expect(written).toContain('rhdh-cli v0.1.3');
expect(written).not.toContain('@backstage/cli-module-actions');
});

it('exits with code 1 when the child process closes with no exit code', () => {
const child = createFakeChild();
mockSpawn.mockReturnValue(child as unknown as ReturnType<typeof spawn>);
Expand All @@ -94,7 +121,7 @@ describe('execPassthrough', () => {
child.emit('error', new Error('ENOENT'));

expect(stderrSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to launch backstage-cli: ENOENT'),
expect.stringContaining('Failed to launch CLI module: ENOENT'),
);
expect(exitSpy).toHaveBeenCalledWith(1);
});
Expand Down
83 changes: 63 additions & 20 deletions src/commands/intent-based-actions/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';

let resolvedCliBinary: string | undefined;
const resolvedCliBinaries = new Map<string, string>();

// Resolves the `backstage-cli` binary from the `@backstage/cli` dependency
// via Node's module resolution, so we always run a known, trusted version.
function resolveBackstageCliBinary(): string {
if (resolvedCliBinary) return resolvedCliBinary;
// Resolve the dedicated CLI module instead of the aggregate Backstage CLI. The
// aggregate CLI discovers modules from the consumer project's package.json,
// which emits a fallback warning when rhdh-cli is executed through npx.
function resolveCliModuleBinary(command: string): string {
const cached = resolvedCliBinaries.get(command);
if (cached) return cached;

const moduleName = `@backstage/cli-module-${command}`;

let pkgJsonPath: string;
try {
pkgJsonPath = require.resolve('@backstage/cli/package.json');
pkgJsonPath = require.resolve(`${moduleName}/package.json`);
} catch {
throw new Error(
'Unable to locate the "@backstage/cli" dependency. Try reinstalling ' +
`Unable to locate the "${moduleName}" dependency. Try reinstalling ` +
'dependencies (e.g. `yarn install`).',
);
}
Expand All @@ -23,35 +27,70 @@
bin?: string | Record<string, string>;
};
const relBin =
typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.['backstage-cli'];
typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.[`cli-module-${command}`];
if (!relBin) {
throw new Error(
'Unable to locate the "backstage-cli" binary: the installed ' +
'@backstage/cli package does not declare it.',
`Unable to locate the CLI binary: the installed ${moduleName} package ` +
'does not declare it.',
);
}

resolvedCliBinary = join(dirname(pkgJsonPath), relBin);
return resolvedCliBinary;
const resolved = join(dirname(pkgJsonPath), relBin);
resolvedCliBinaries.set(command, resolved);
return resolved;
}

// Keeps output consistently branded as `rhdh-cli`.
const upstreamCliNames = [
'backstage-cli',
'@backstage/cli-module-actions',
'@backstage/cli-module-auth',
];

function findUpstreamCliName(text: string) {
return upstreamCliNames
.map(name => ({ name, index: text.indexOf(name) }))
.filter(({ index }) => index >= 0)
.sort((a, b) => a.index - b.index)[0];
}

function rebrand(text: string): string {
return text.replace(/backstage-cli/g, 'rhdh-cli');
return text
.replace(/backstage-cli/g, 'rhdh-cli')

Check warning on line 59 in src/commands/intent-based-actions/client.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#replace()`.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-cli&issues=AaBdNxkmaFZ7ciumR1JQ&open=AaBdNxkmaFZ7ciumR1JQ&pullRequest=172
.replace(/@backstage\/cli-module-(?:actions|auth)/g, 'rhdh-cli');
}

// Rebrands output as it streams in, without buffering more than a couple
// characters at a time, so interactive commands still feel responsive.
function createRebrandingWriter(target: NodeJS.WritableStream) {
const tailLength = 'backstage-cli'.length - 1;
let pending = '';

function flushCompleteNames() {
let match = findUpstreamCliName(pending);
while (match) {
target.write(`${pending.slice(0, match.index)}rhdh-cli`);
pending = pending.slice(match.index + match.name.length);
match = findUpstreamCliName(pending);
}
}

return {
write(chunk: Buffer | string) {
pending += chunk.toString();
if (pending.length <= tailLength) return;
const flushEnd = pending.length - tailLength;
target.write(rebrand(pending.slice(0, flushEnd)));
pending = pending.slice(flushEnd);
flushCompleteNames();
const retainedLength = Math.max(
0,
...upstreamCliNames.flatMap(name =>
Array.from(
{ length: name.length - 1 },
(_, index) => index + 1,
).filter(length => pending.endsWith(name.slice(0, length))),
),
);
if (pending.length > retainedLength) {
target.write(pending.slice(0, pending.length - retainedLength));
pending = pending.slice(pending.length - retainedLength);
}
},
end() {
if (pending) target.write(rebrand(pending));
Expand All @@ -61,7 +100,11 @@
}

export function execPassthrough(args: string[]): void {
const bin = resolveBackstageCliBinary();
const command = args[0];
if (command !== 'actions' && command !== 'auth') {
throw new Error(`Unsupported pass-through command: ${command}`);
}
const bin = resolveCliModuleBinary(command);
const child = spawn(process.execPath, [bin, ...args], {
stdio: ['inherit', 'pipe', 'pipe'],
timeout: 120_000,
Expand All @@ -75,7 +118,7 @@
child.on('error', (error: NodeJS.ErrnoException) => {
stdout.end();
stderr.end();
process.stderr.write(`Failed to launch backstage-cli: ${error.message}\n`);
process.stderr.write(`Failed to launch CLI module: ${error.message}\n`);
process.exit(1);
});

Expand Down
6 changes: 4 additions & 2 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1726,7 +1726,7 @@ __metadata:
languageName: node
linkType: hard

"@backstage/cli-module-actions@npm:^0.1.2, @backstage/cli-module-actions@npm:^0.1.3":
"@backstage/cli-module-actions@npm:0.1.3, @backstage/cli-module-actions@npm:^0.1.2, @backstage/cli-module-actions@npm:^0.1.3":
version: 0.1.3
resolution: "@backstage/cli-module-actions@npm:0.1.3"
dependencies:
Expand All @@ -1744,7 +1744,7 @@ __metadata:
languageName: node
linkType: hard

"@backstage/cli-module-auth@npm:^0.1.3, @backstage/cli-module-auth@npm:^0.1.4":
"@backstage/cli-module-auth@npm:0.1.4, @backstage/cli-module-auth@npm:^0.1.3, @backstage/cli-module-auth@npm:^0.1.4":
version: 0.1.4
resolution: "@backstage/cli-module-auth@npm:0.1.4"
dependencies:
Expand Down Expand Up @@ -4970,6 +4970,8 @@ __metadata:
"@backstage/cli": "npm:0.36.3"
"@backstage/cli-common": "npm:0.2.2"
"@backstage/cli-defaults": "npm:0.1.3"
"@backstage/cli-module-actions": "npm:0.1.3"
"@backstage/cli-module-auth": "npm:0.1.4"
"@backstage/cli-module-build": "npm:0.1.4"
"@backstage/cli-module-config": "npm:0.1.3"
"@backstage/cli-module-test-jest": "npm:0.1.3"
Expand Down
Loading