diff --git a/package.json b/package.json index 53c3dbd..10aeff2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/commands/intent-based-actions/client.test.ts b/src/commands/intent-based-actions/client.test.ts index 735a434..899814c 100644 --- a/src/commands/intent-based-actions/client.test.ts +++ b/src/commands/intent-based-actions/client.test.ts @@ -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', @@ -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); + + 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); @@ -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); + + 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); @@ -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); }); diff --git a/src/commands/intent-based-actions/client.ts b/src/commands/intent-based-actions/client.ts index d2093fd..665f1c7 100644 --- a/src/commands/intent-based-actions/client.ts +++ b/src/commands/intent-based-actions/client.ts @@ -2,19 +2,23 @@ import { spawn } from 'node:child_process'; import { readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; -let resolvedCliBinary: string | undefined; +const resolvedCliBinaries = new Map(); -// 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`).', ); } @@ -23,35 +27,70 @@ function resolveBackstageCliBinary(): string { bin?: string | Record; }; 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') + .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)); @@ -61,7 +100,11 @@ function createRebrandingWriter(target: NodeJS.WritableStream) { } 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, @@ -75,7 +118,7 @@ export function execPassthrough(args: string[]): void { 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); }); diff --git a/yarn.lock b/yarn.lock index d63a9a0..9b32e3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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: @@ -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: @@ -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"