From 8a592cde7834b3013e56e2e78c75b0159511cd87 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 05:32:11 +0000 Subject: [PATCH 1/5] Add reporter bootstrap handoff Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/IRushFrontendLaunchOptions.ts | 7 + apps/rush/src/RushCommandSelector.ts | 88 ++- apps/rush/src/RushFrontend.ts | 4 +- apps/rush/src/RushReporterHost.ts | 74 ++- apps/rush/src/RushVersionSelector.ts | 29 +- .../rush/src/test/RushCommandSelector.test.ts | 173 ++++++ apps/rush/src/test/RushFrontend.test.ts | 2 + apps/rush/src/test/RushReporterHost.test.ts | 95 ++- ...6a-bootstrap-handoff_2026-08-28-04-40.json | 11 + ...6a-bootstrap-handoff_2026-08-28-04-40.json | 11 + common/reviews/api/rush-reporter.api.md | 7 + .../src/bootstrap/BootstrapProtocol.ts | 4 +- .../reporter/src/frontend/ReporterHost.ts | 63 +- libraries/reporter/src/index.ts | 6 +- .../reporter/src/test/ReporterHost.test.ts | 7 +- .../src/scripts/InstallRunRushBootstrap.ts | 572 ++++++++++++++++++ .../scripts/generated/BootstrapProtocol.ts | 45 ++ .../rush-lib/src/scripts/install-run-rush.ts | 73 ++- libraries/rush-lib/src/scripts/install-run.ts | 209 ++++++- .../test/InstallRunRushBootstrap.test.ts | 261 ++++++++ libraries/rush-lib/webpack.config.js | 57 +- 21 files changed, 1704 insertions(+), 94 deletions(-) create mode 100644 apps/rush/src/test/RushCommandSelector.test.ts create mode 100644 common/changes/@microsoft/rush/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json create mode 100644 common/changes/@rushstack/rush-reporter/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json create mode 100644 libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts create mode 100644 libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts index 4b3bf391a67..99c82748768 100644 --- a/apps/rush/src/IRushFrontendLaunchOptions.ts +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -15,4 +15,11 @@ import type { IReporterEventSink } from '@rushstack/rush-reporter'; export interface IRushFrontendLaunchOptions extends ILaunchOptions { readonly reporterEventSink: IReporterEventSink; readonly reporterCloseAsync: () => Promise; + readonly reporterEnabled: boolean; + readonly reporterSelectionReason: + | 'explicit --reporter' + | 'repository experiment' + | 'RUSH_REPORTER=legacy' + | 'pre-major legacy default' + | 'bootstrap compatibility fallback'; } diff --git a/apps/rush/src/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index 8d29eac6afa..0453811b4de 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -2,6 +2,14 @@ // See LICENSE in the project root for license information. import * as path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; + +import { + OldEngineOutputAdapter, + REPORTER_PROTOCOL_VERSION, + resolveReporterCompatibility, + type IReporterCompatibilityDecision +} from '@rushstack/rush-reporter'; import type { IRushFrontendLaunchOptions } from './IRushFrontendLaunchOptions'; @@ -37,6 +45,37 @@ export class RushCommandSelector { } const commandName: CommandName = _getCommandName(); + const engineProtocolMajor: number | undefined = ( + Rush as typeof Rush & { readonly _reporterProtocolMajor?: number } + )._reporterProtocolMajor; + const compatibility: IReporterCompatibilityDecision = resolveReporterCompatibility( + { protocolMajor: REPORTER_PROTOCOL_VERSION.major, hasManager: true }, + { + supportsStructuredSink: engineProtocolMajor !== undefined, + protocolMajor: engineProtocolMajor + } + ); + let effectiveOptions: IRushFrontendLaunchOptions = options; + if (compatibility.mode === 'new-frontend-old-engine' && options.reporterEnabled) { + _observeOldEngineOutput(options, Rush.version); + } else if ( + compatibility.mode === 'old-frontend-new-engine' && + engineProtocolMajor !== undefined && + options.reporterEnabled + ) { + if (options.reporterSelectionReason === 'explicit --reporter') { + throw new Error( + `The selected Rush engine uses reporter protocol major ${engineProtocolMajor}, but this ` + + `frontend supports major ${REPORTER_PROTOCOL_VERSION.major}. Update global Rush or use ` + + '--reporter=legacy.' + ); + } + effectiveOptions = { + ...options, + reporterEnabled: false, + reporterSelectionReason: 'bootstrap compatibility fallback' + }; + } if (commandName === 'rush-pnpm') { if (!Rush.launchRushPnpm) { @@ -56,13 +95,58 @@ export class RushCommandSelector { ` which does not support the "rushx" command` ); } - Rush.launchRushX(launcherVersion, options); + Rush.launchRushX(launcherVersion, effectiveOptions); } else { - Rush.launch(launcherVersion, options); + Rush.launch(launcherVersion, effectiveOptions); } } } +function _observeOldEngineOutput(options: IRushFrontendLaunchOptions, engineVersion: string): void { + const adapter: OldEngineOutputAdapter = new OldEngineOutputAdapter({ + sink: options.reporterEventSink, + sessionId: `rush_old_engine_${process.pid}`, + source: { packageName: '@microsoft/rush-lib', packageVersion: engineVersion } + }); + const legacyWrite: typeof process.stderr.write = process.stderr.write.bind(process.stderr); + _observeStream(process.stdout, 'stdout', adapter, legacyWrite); + _observeStream(process.stderr, 'stderr', adapter, legacyWrite); +} + +function _observeStream( + stream: NodeJS.WriteStream, + streamName: 'stdout' | 'stderr', + adapter: OldEngineOutputAdapter, + legacyWrite: typeof process.stderr.write +): void { + const marker: symbol = Symbol.for(`rush.reporter.old-engine-output.${streamName}`); + const markedStream: NodeJS.WriteStream & { [key: symbol]: boolean | undefined } = + stream as NodeJS.WriteStream & { [key: symbol]: boolean | undefined }; + if (markedStream[marker]) { + return; + } + markedStream[marker] = true; + + const decoder: StringDecoder = new StringDecoder('utf8'); + stream.write = (( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void + ): boolean => { + const text: string = + typeof chunk === 'string' + ? chunk + : decoder.write(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)); + if (text) { + adapter.capture(streamName, text); + } + if (typeof encodingOrCallback === 'function') { + return legacyWrite(chunk, encodingOrCallback); + } + return legacyWrite(chunk, encodingOrCallback, callback); + }) as typeof stream.write; +} + function _failWithError(message: string): never { throw new Error(message); } diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index 0fc42146f09..da4f535702b 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -155,7 +155,9 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr const reporterLaunchOptions: IRushFrontendLaunchOptions = { ...launchOptions, reporterEventSink: reporterHost.sink, - reporterCloseAsync + reporterCloseAsync, + reporterEnabled: reporterHost.selection.enabled, + reporterSelectionReason: reporterHost.selection.reason }; try { diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index dfa9b84a7e4..0163b3b16aa 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -23,8 +23,12 @@ import { type IReporterEventEnvelope, type IReporterEventSink, type IReporterOutputTarget, + type IBootstrapReplayResult, type ReporterLogLevel, - type ReporterName + type ReporterName, + LegacyFallbackSink, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR } from '@rushstack/rush-reporter'; export interface IRushReporterOutputStream { @@ -38,11 +42,15 @@ export interface IRushReporterHostOptions { readonly env?: Record; readonly cwd?: string; readonly stdout?: IRushReporterOutputStream; + readonly stderr?: IRushReporterOutputStream; readonly includeDefaultFileReporter?: boolean; readonly commandName?: 'rush' | 'rush-pnpm' | 'rushx'; readonly repositoryOptIn?: boolean; readonly forceLegacy?: boolean; readonly selectedRushVersion?: string; + readonly handoffDirectory?: string; + readonly handoffRetentionMs?: number; + readonly nowMs?: () => number; } export interface IRushReporterSelection { @@ -57,7 +65,8 @@ export interface IRushReporterSelection { | 'explicit --reporter' | 'repository experiment' | 'RUSH_REPORTER=legacy' - | 'pre-major legacy default'; + | 'pre-major legacy default' + | 'bootstrap compatibility fallback'; } export interface IInitializedRushReporterHost { @@ -65,6 +74,8 @@ export interface IInitializedRushReporterHost { readonly sink: IReporterEventSink; readonly selection: IRushReporterSelection; closeAsync(timeoutMs?: number): Promise; + readonly bootstrapReplay: IBootstrapReplayResult; + readonly abandonedHandoffFilesDeleted: readonly string[]; } const REPORTER_VALUE_FLAGS: ReadonlySet = new Set(['--reporter', '--output', '--log-level']); @@ -603,9 +614,23 @@ export async function initializeRushReporterHostAsync( options: IRushReporterHostOptions = {} ): Promise { const env: Record = options.env ?? process.env; - const stdout: IRushReporterOutputStream = options.stdout ?? process.stdout; - const selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); - const host: ReporterHost = new ReporterHost({ env }); + const stdout: IRushReporterOutputStream = options.stdout ?? { + isTTY: process.stdout.isTTY, + columns: process.stdout.columns, + write: process.stdout.write.bind(process.stdout) + }; + const stderr: IRushReporterOutputStream = options.stderr ?? { + isTTY: process.stderr.isTTY, + columns: process.stderr.columns, + write: process.stderr.write.bind(process.stderr) + }; + let selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); + const host: ReporterHost = new ReporterHost({ + env, + handoffDirectory: options.handoffDirectory, + retentionMs: options.handoffRetentionMs, + nowMs: options.nowMs + }); if (selection.enabled) { const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); @@ -640,11 +665,48 @@ export async function initializeRushReporterHostAsync( } await host.manager.initializeAsync(); + let bootstrapReplay: IBootstrapReplayResult; + try { + bootstrapReplay = await host.replayBootstrapHandoffAsync(); + } finally { + delete env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + delete env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]; + } + const abandonedHandoffFilesDeleted: readonly string[] = await host.cleanAbandonedHandoffFilesAsync(); + + let sink: IReporterEventSink = host.getSink(); + if (bootstrapReplay.skipReason === 'incompatible-protocol') { + for (const output of bootstrapReplay.legacyFallbackOutput ?? []) { + const target: IRushReporterOutputStream = + selection.reason === 'explicit --reporter' ? stderr : output.stream === 'stdout' ? stdout : stderr; + target.write(output.text); + } + if (selection.reason === 'explicit --reporter') { + throw new Error( + 'The install-run-rush bootstrap reporter protocol is incompatible with this Rush frontend. ' + + 'Update the global Rush installation or use --reporter=legacy.' + ); + } + selection = { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: selection.commandJson, + enabled: false, + reporterControlsOwnedByFrontend: selection.reporterControlsOwnedByFrontend, + reporterValueFlagsToStrip: selection.reporterValueFlagsToStrip, + reason: 'bootstrap compatibility fallback' + }; + sink = new LegacyFallbackSink(); + } + let closePromise: Promise | undefined; return { host, - sink: host.getSink(), + sink, selection, + bootstrapReplay, + abandonedHandoffFilesDeleted, closeAsync: (timeoutMs?: number) => { closePromise ??= host.manager.closeAsync(timeoutMs); return closePromise; diff --git a/apps/rush/src/RushVersionSelector.ts b/apps/rush/src/RushVersionSelector.ts index 6e450e7aca0..077152d5444 100644 --- a/apps/rush/src/RushVersionSelector.ts +++ b/apps/rush/src/RushVersionSelector.ts @@ -39,16 +39,19 @@ export class RushVersionSelector { let installIsValid: boolean = await installMarker.isValidAsync(); if (!installIsValid) { // Need to install Rush - console.log(`Rush version ${version} is not currently installed. Installing...`); + this._reportStartupMessage( + executeOptions, + `Rush version ${version} is not currently installed. Installing...` + ); const resourceName: string = `rush-${version}`; - console.log(`Trying to acquire lock for ${resourceName}`); + this._reportStartupMessage(executeOptions, `Trying to acquire lock for ${resourceName}`); const lock: LockFile = await LockFile.acquireAsync(expectedRushPath, resourceName); installIsValid = await installMarker.isValidAsync(); if (installIsValid) { - console.log('Another process performed the installation.'); + this._reportStartupMessage(executeOptions, 'Another process performed the installation.'); } else { await Utilities.installPackageInDirectoryAsync({ directory: expectedRushPath, @@ -69,7 +72,10 @@ export class RushVersionSelector { filterNpmIncompatibleProperties: true }); - console.log(`Successfully installed Rush version ${version} in ${expectedRushPath}.`); + this._reportStartupMessage( + executeOptions, + `Successfully installed Rush version ${version} in ${expectedRushPath}.` + ); // If we've made it here without exception, write the flag file await installMarker.createAsync(); @@ -101,4 +107,19 @@ export class RushVersionSelector { RushCommandSelector.execute(this._currentPackageVersion, rushCliEntrypoint, executeOptions); } } + + private _reportStartupMessage(options: IRushFrontendLaunchOptions, text: string): void { + if (options.reporterEnabled) { + options.reporterEventSink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: `rush_frontend_${process.pid}`, + source: { packageName: '@microsoft/rush', packageVersion: this._currentPackageVersion }, + privacy: 'public', + type: 'activityChanged', + payload: { kind: 'version-selection', text } + }); + } else { + console.log(text); + } + } } diff --git a/apps/rush/src/test/RushCommandSelector.test.ts b/apps/rush/src/test/RushCommandSelector.test.ts new file mode 100644 index 00000000000..3ec4e45375a --- /dev/null +++ b/apps/rush/src/test/RushCommandSelector.test.ts @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { ReporterManager, type IReporter, type IReporterEventEnvelope } from '@rushstack/rush-reporter'; + +import { RushCommandSelector } from '../RushCommandSelector'; +import type { IRushFrontendLaunchOptions } from '../IRushFrontendLaunchOptions'; + +class RecordingReporter implements IReporter { + public readonly name: string = 'recording'; + public readonly events: IReporterEventEnvelope[] = []; + + public async initializeAsync(): Promise {} + + public report(event: IReporterEventEnvelope): void { + this.events.push(event); + } + + public async flushAsync(): Promise {} + + public async closeAsync(): Promise {} +} + +describe(RushCommandSelector.name, () => { + it('keeps old-engine legacy output visible while bridging it to the frontend host', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + const marker: symbol = Symbol.for('rush.reporter.old-engine-output.stdout'); + const markedStdout: NodeJS.WriteStream & { [key: symbol]: boolean | undefined } = + process.stdout as unknown as NodeJS.WriteStream & { [key: symbol]: boolean | undefined }; + let visibleOutput: string = ''; + process.argv = ['node', 'rush', 'build']; + process.stderr.write = ((text: string): boolean => { + visibleOutput += text; + return true; + }) as typeof process.stderr.write; + + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporterEventSink: manager, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }; + const oldRushLib = { + Rush: { + version: '5.177.0', + launch: () => { + process.stdout.write('legacy engine output\n'); + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + try { + RushCommandSelector.execute('5.178.1', oldRushLib, options); + await manager.flushAsync(); + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + delete markedStdout[marker]; + delete (process.stderr as unknown as { [key: symbol]: boolean | undefined })[ + Symbol.for('rush.reporter.old-engine-output.stderr') + ]; + process.argv = originalArgv; + } + + expect(visibleOutput).toBe('legacy engine output\n'); + expect(reporter.events).toHaveLength(1); + expect(reporter.events[0]).toMatchObject({ + type: 'externalOutput', + payload: { stream: 'stdout', text: 'legacy engine output\n' } + }); + }); + + it('preserves a UTF-8 code point split across old-engine buffer writes', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + const marker: symbol = Symbol.for('rush.reporter.old-engine-output.stdout'); + const markedStdout: NodeJS.WriteStream & { [key: symbol]: boolean | undefined } = + process.stdout as unknown as NodeJS.WriteStream & { [key: symbol]: boolean | undefined }; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = (() => true) as typeof process.stdout.write; + process.stderr.write = (() => true) as typeof process.stderr.write; + + const oldRushLib = { + Rush: { + version: '5.177.0', + launch: () => { + process.stdout.write(Buffer.from([0xe2])); + process.stdout.write(Buffer.from([0x82, 0xac])); + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + try { + RushCommandSelector.execute('5.178.1', oldRushLib, { + isManaged: true, + reporterEventSink: manager, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }); + await manager.flushAsync(); + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + delete markedStdout[marker]; + delete (process.stderr as unknown as { [key: symbol]: boolean | undefined })[ + Symbol.for('rush.reporter.old-engine-output.stderr') + ]; + process.argv = originalArgv; + } + + expect(reporter.events).toHaveLength(1); + expect(reporter.events[0].payload).toEqual({ stream: 'stdout', text: '€' }); + }); + + it('fails an explicit reporter request for an incompatible new engine protocol', () => { + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporterEventSink: new ReporterManager(), + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }; + const incompatibleRushLib = { + Rush: { + version: '6.0.0', + _reporterProtocolMajor: 2, + launch: () => undefined + } + } as unknown as typeof import('@microsoft/rush-lib'); + + expect(() => RushCommandSelector.execute('5.178.1', incompatibleRushLib, options)).toThrow( + /reporter protocol major 2/ + ); + }); + + it('falls back to legacy engine rendering for an implicit incompatible protocol', () => { + let receivedOptions: IRushFrontendLaunchOptions | undefined; + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporterEventSink: new ReporterManager(), + reporterEnabled: true, + reporterSelectionReason: 'repository experiment' + }; + const incompatibleRushLib = { + Rush: { + version: '6.0.0', + _reporterProtocolMajor: 2, + launch: (launcherVersion: string, launchOptions: IRushFrontendLaunchOptions) => { + void launcherVersion; + receivedOptions = launchOptions; + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + RushCommandSelector.execute('5.178.1', incompatibleRushLib, options); + expect(receivedOptions).toMatchObject({ + reporterEnabled: false, + reporterSelectionReason: 'bootstrap compatibility fallback' + }); + }); +}); diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index 30b4081f405..3e4135372fb 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -38,6 +38,8 @@ async function createInitializedHostAsync( return { host, sink: host.getSink(), + bootstrapReplay: { direct: true, replayed: false, eventCount: 0 }, + abandonedHandoffFilesDeleted: [], selection: { reporter: 'legacy', logLevel: 'normal', diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index fc3e630773e..77b3bf1a6a1 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -6,6 +6,12 @@ import * as os from 'node:os'; import * as path from 'node:path'; import type { IReporterEventSink } from '@rushstack/rush-reporter'; +import { + BootstrapEventBuffer, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR, + writeBootstrapHandoffFileAsync +} from '@rushstack/rush-reporter'; import { initializeRushReporterHostAsync, @@ -68,9 +74,11 @@ describe(resolveRushReporterSelection.name, () => { enabled: true, reason: 'explicit --reporter' }); - expect(() => resolve(['build'], { RUSH_REPORTER: 'json' })).toThrow( - /cannot enable the pre-major reporter path/ - ); + expect(resolve(['build'], { RUSH_REPORTER: 'json' })).toMatchObject({ + reporter: 'legacy', + enabled: false, + reason: 'pre-major legacy default' + }); }); it('uses deterministic non-agent selection for the repository experiment', () => { @@ -458,4 +466,85 @@ describe(initializeRushReporterHostAsync.name, () => { await fs.promises.rm(directory, { recursive: true, force: true }); } }); + + it('replays and deletes a bootstrap handoff before returning the authoritative host', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ type: 'sessionStarted', payload: { rushVersion: '5.178.1' } }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json'], + env, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + await initialized.host.manager.flushAsync(); + + expect(initialized.bootstrapReplay).toMatchObject({ replayed: true, eventCount: 1 }); + expect(fs.existsSync(handoffPath)).toBe(false); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBeUndefined(); + expect(JSON.parse(stdoutText).type).toBe('sessionStarted'); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('restores ordered legacy output when repository opt-in meets an incompatible handoff', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ type: 'activityChanged', payload: { text: 'installing Rush' } }); + buffer.addExternalOutput('stdout', 'npm output\n'); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const contents: string = await fs.promises.readFile(handoffPath, 'utf8'); + await fs.promises.writeFile(handoffPath, contents.replace(/"major":1/g, '"major":2')); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: ['build'], + env, + repositoryOptIn: true, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + + expect(initialized.bootstrapReplay.skipReason).toBe('incompatible-protocol'); + expect(initialized.selection).toMatchObject({ + enabled: false, + reason: 'bootstrap compatibility fallback' + }); + expect(stdoutText).toBe('installing Rush\nnpm output\n'); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); }); diff --git a/common/changes/@microsoft/rush/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json b/common/changes/@microsoft/rush/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json new file mode 100644 index 00000000000..0405adb2302 --- /dev/null +++ b/common/changes/@microsoft/rush/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add a bounded nonce-protected install-run-rush handoff, replay it before version selection, and bridge cross-version reporter compatibility.", + "type": "patch" + } + ], + "packageName": "@microsoft/rush", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/rush-reporter/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json b/common/changes/@rushstack/rush-reporter/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json new file mode 100644 index 00000000000..aa1ab9990ea --- /dev/null +++ b/common/changes/@rushstack/rush-reporter/copilot-reporter-r6a-bootstrap-handoff_2026-08-28-04-40.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-reporter", + "comment": "Preserve ordered legacy fallback output when a bootstrap handoff protocol is incompatible.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-reporter", + "email": "223556219+Copilot@users.noreply.github.com" +} diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 0d8ccfa2dae..342b21b8754 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -330,11 +330,18 @@ export interface IBootstrapHandoffWriteResult { readonly nonce: string; } +// @beta +export interface IBootstrapLegacyOutput { + readonly stream: 'stdout' | 'stderr'; + readonly text: string; +} + // @beta export interface IBootstrapReplayResult { readonly direct: boolean; readonly eventCount: number; readonly handoffPath?: string; + readonly legacyFallbackOutput?: readonly IBootstrapLegacyOutput[]; readonly replayed: boolean; readonly skippedEventCount?: number; readonly skipReason?: 'unreadable' | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' | 'incompatible-protocol'; diff --git a/libraries/reporter/src/bootstrap/BootstrapProtocol.ts b/libraries/reporter/src/bootstrap/BootstrapProtocol.ts index e834f2f49d8..64821501ae3 100644 --- a/libraries/reporter/src/bootstrap/BootstrapProtocol.ts +++ b/libraries/reporter/src/bootstrap/BootstrapProtocol.ts @@ -74,8 +74,6 @@ export function encodeBootstrapEnvelope(input: IBootstrapEnvelopeInput): string }); } -// END GENERATED BOOTSTRAP PROTOCOL - /** * The maximum size of the buffered bootstrap event stream, in bytes (1 MiB). * @@ -120,3 +118,5 @@ export const RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_NO */ export const BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME: 'rush.reporter.buffer-truncated' = 'rush.reporter.buffer-truncated'; + +// END GENERATED BOOTSTRAP PROTOCOL diff --git a/libraries/reporter/src/frontend/ReporterHost.ts b/libraries/reporter/src/frontend/ReporterHost.ts index 0b6acdd3787..770986282d0 100644 --- a/libraries/reporter/src/frontend/ReporterHost.ts +++ b/libraries/reporter/src/frontend/ReporterHost.ts @@ -11,10 +11,7 @@ import type { ReporterEventType } from '../events/ReporterEventType'; import type { IReporterEventSink } from '../producers/IReporterEventSink'; import { REPORTER_EVENT_TYPES } from '../events/ReporterEventType'; import { ReporterManager } from '../manager/ReporterManager'; -import { - REPORTER_PROTOCOL_VERSION, - isReporterProtocolCompatible -} from '../protocol/ReporterProtocol'; +import { REPORTER_PROTOCOL_VERSION, isReporterProtocolCompatible } from '../protocol/ReporterProtocol'; import { RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR @@ -101,7 +98,35 @@ export interface IBootstrapReplayResult { * The reason no events were replayed, when a handoff path was present. * `nonce-mismatch` means the file failed authentication and was rejected. */ - readonly skipReason?: 'unreadable' | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' | 'incompatible-protocol'; + readonly skipReason?: + | 'unreadable' + | 'invalid-path' + | 'nonce-mismatch' + | 'invalid-event' + | 'incompatible-protocol'; + + /** + * Ordered raw output that a legacy fallback can render when the handoff + * protocol is incompatible. + */ + readonly legacyFallbackOutput?: readonly IBootstrapLegacyOutput[]; +} + +/** + * A raw bootstrap write retained for legacy-visible fallback. + * + * @beta + */ +export interface IBootstrapLegacyOutput { + /** + * The original output stream. + */ + readonly stream: 'stdout' | 'stderr'; + + /** + * The unmodified output text. + */ + readonly text: string; } function isRecord(value: unknown): value is Record { @@ -143,6 +168,25 @@ function isReporterEventEnvelope(value: unknown): value is IReporterEventEnvelop ); } +function getLegacyFallbackOutput(events: readonly unknown[]): IBootstrapLegacyOutput[] { + const output: IBootstrapLegacyOutput[] = []; + for (const event of events) { + if (!isRecord(event) || !isRecord(event.payload)) { + continue; + } + if ( + event.type === 'externalOutput' && + (event.payload.stream === 'stdout' || event.payload.stream === 'stderr') && + typeof event.payload.text === 'string' + ) { + output.push({ stream: event.payload.stream, text: event.payload.text }); + } else if (event.type === 'activityChanged' && typeof event.payload.text === 'string') { + output.push({ stream: 'stdout', text: `${event.payload.text}\n` }); + } + } + return output; +} + /** * Hosts the authoritative {@link ReporterManager} in the frontend, before Rush * version selection. @@ -247,17 +291,16 @@ export class ReporterHost { let skippedEventCount: number = discardedRecordCount; for (const event of events) { const protocolVersion: IReporterProtocolVersion | undefined = getProtocolVersion(event); - if ( - protocolVersion && - !isReporterProtocolCompatible(REPORTER_PROTOCOL_VERSION, protocolVersion) - ) { + if (protocolVersion && !isReporterProtocolCompatible(REPORTER_PROTOCOL_VERSION, protocolVersion)) { + const legacyFallbackOutput: IBootstrapLegacyOutput[] = getLegacyFallbackOutput(events); await deleteBootstrapHandoffFileAsync(handoffPath); return { direct: false, replayed: false, eventCount: 0, handoffPath, - skipReason: 'incompatible-protocol' + skipReason: 'incompatible-protocol', + ...(legacyFallbackOutput.length > 0 ? { legacyFallbackOutput } : {}) }; } if (!isReporterEventEnvelope(event)) { diff --git a/libraries/reporter/src/index.ts b/libraries/reporter/src/index.ts index fcff5af94f8..7cba1719773 100644 --- a/libraries/reporter/src/index.ts +++ b/libraries/reporter/src/index.ts @@ -138,7 +138,11 @@ export { export type { IEarlyReporterControls } from './bootstrap/EarlyReporterControls'; export { parseEarlyReporterControls } from './bootstrap/EarlyReporterControls'; -export type { IReporterHostOptions, IBootstrapReplayResult } from './frontend/ReporterHost'; +export type { + IReporterHostOptions, + IBootstrapReplayResult, + IBootstrapLegacyOutput +} from './frontend/ReporterHost'; export { ReporterHost, DEFAULT_HANDOFF_RETENTION_MS } from './frontend/ReporterHost'; export type { diff --git a/libraries/reporter/src/test/ReporterHost.test.ts b/libraries/reporter/src/test/ReporterHost.test.ts index 208c458b761..4ebca154427 100644 --- a/libraries/reporter/src/test/ReporterHost.test.ts +++ b/libraries/reporter/src/test/ReporterHost.test.ts @@ -188,6 +188,7 @@ describe('ReporterHost handoff replay', () => { await withTempDir(async (directory: string) => { const buffer: BootstrapEventBuffer = makeBuffer(); buffer.emit({ type: 'sessionStarted', payload: {} }); + buffer.addExternalOutput('stderr', 'legacy bootstrap output\n'); const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); const contents: string = await fs.promises.readFile(handoffPath, 'utf8'); await fs.promises.writeFile(handoffPath, contents.replace('"major":1', '"major":2')); @@ -204,6 +205,7 @@ describe('ReporterHost handoff replay', () => { }); const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); expect(result.skipReason).toBe('incompatible-protocol'); + expect(result.legacyFallbackOutput).toEqual([{ stream: 'stderr', text: 'legacy bootstrap output\n' }]); }); }); @@ -267,10 +269,7 @@ describe('ReporterHost handoff replay', () => { const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); await manager.flushAsync(); expect(result).toMatchObject({ replayed: true, eventCount: 2, skippedEventCount: 1 }); - expect(reporter.reported.map((event) => event.type)).toEqual([ - 'sessionStarted', - 'diagnosticEmitted' - ]); + expect(reporter.reported.map((event) => event.type)).toEqual(['sessionStarted', 'diagnosticEmitted']); }); }); diff --git a/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts new file mode 100644 index 00000000000..fc86bc729e1 --- /dev/null +++ b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts @@ -0,0 +1,572 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// IMPORTANT: This file is bundled into install-run-rush.js and must use only Node.js built-ins. + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { ILogger } from '../utilities/npmrcUtilities'; +import { + BOOTSTRAP_BUFFER_MAX_BYTES, + BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME, + BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES, + BOOTSTRAP_PROTOCOL_MAJOR, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR, + encodeBootstrapEnvelope +} from './generated/BootstrapProtocol'; + +const TRUNCATION_NOTICE_RESERVE_BYTES: number = 512; +const BOOTSTRAP_HANDOFF_FILE_PREFIX: string = 'rush-reporter-bootstrap-'; +const BOOTSTRAP_HANDOFF_FILE_SUFFIX: string = '.ndjson'; +const SUPPORTED_REPORTERS: ReadonlySet = new Set([ + 'default', + 'ai', + 'json', + 'plaintext', + 'file', + 'legacy' +]); +const SUPPORTED_LOG_LEVELS: ReadonlySet = new Set(['quiet', 'normal', 'verbose', 'debug']); + +type BootstrapStream = 'stdout' | 'stderr'; + +interface IBootstrapEventInput { + readonly type: string; + readonly privacy: 'public' | 'local-sensitive'; + readonly payload: unknown; +} + +interface IBufferedBootstrapEntry { + readonly line: string; + readonly bytes: number; + readonly required: boolean; + readonly fallbackWrite?: IFallbackWrite; +} + +interface IFallbackWrite { + readonly stream: BootstrapStream; + readonly text: string; +} + +export interface IInstallRunRushBootstrapOptions { + readonly argv: readonly string[]; + readonly env: Record; + readonly rushJsonFolder: string; + readonly rushVersion: string; + readonly bootstrapVersion: string; + readonly commandName: 'rush' | 'rush-pnpm' | 'rushx'; + readonly quiet: boolean; + readonly stdout?: (text: string) => void; + readonly stderr?: (text: string) => void; + readonly handoffDirectory?: string; + readonly maxBytes?: number; + readonly now?: () => string; + readonly randomUUID?: () => string; +} + +export interface IInstallRunRushBootstrap { + readonly enabled: boolean; + readonly logger: ILogger; + readonly externalOutputCaptureMaxBytes: number | undefined; + readonly externalOutputHandler: ((stream: BootstrapStream, text: string) => void) | undefined; + readonly externalOutputOverflowHandler: (() => void) | undefined; + readonly prepareToRun: (() => void) | undefined; +} + +function readSingleFlagValue(argv: readonly string[], flag: string): string | undefined { + let result: string | undefined; + const prefix: string = `${flag}=`; + for (let index: number = 0; index < argv.length; index++) { + const argument: string = argv[index]; + let value: string | undefined; + if (argument.startsWith(prefix)) { + value = argument.slice(prefix.length); + } else if (argument === flag) { + value = argv[index + 1]; + if (!value || value.startsWith('-')) { + throw new Error(`${flag} requires a value.`); + } + index++; + } + + if (value !== undefined) { + if (!value) { + throw new Error(`${flag} requires a value.`); + } + if (result !== undefined) { + throw new Error(`${flag} may be specified only once.`); + } + result = value; + } + } + return result; +} + +function repositoryUsesRushReporter(rushJsonFolder: string): boolean { + const experimentsPath: string = path.join(rushJsonFolder, 'common', 'config', 'rush', 'experiments.json'); + let contents: string; + try { + contents = fs.readFileSync(experimentsPath, 'utf8'); + } catch (error) { + const code: unknown = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT') { + return false; + } + throw error; + } + + const matches: RegExpMatchArray[] = [ + ...stripJsonComments(contents).matchAll(/"useRushReporter"\s*:\s*(true|false)/g) + ]; + return matches.length > 0 && matches[matches.length - 1][1] === 'true'; +} + +function stripJsonComments(text: string): string { + let result: string = ''; + let inString: boolean = false; + let escaped: boolean = false; + let lineComment: boolean = false; + let blockComment: boolean = false; + + for (let index: number = 0; index < text.length; index++) { + const character: string = text[index]; + const nextCharacter: string | undefined = text[index + 1]; + if (lineComment) { + if (character === '\n' || character === '\r') { + lineComment = false; + result += character; + } + continue; + } + if (blockComment) { + if (character === '*' && nextCharacter === '/') { + blockComment = false; + index++; + } else if (character === '\n' || character === '\r') { + result += character; + } + continue; + } + if (inString) { + result += character; + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === '"') { + inString = false; + } + continue; + } + if (character === '"') { + inString = true; + result += character; + } else if (character === '/' && nextCharacter === '/') { + lineComment = true; + index++; + } else if (character === '/' && nextCharacter === '*') { + blockComment = true; + index++; + } else { + result += character; + } + } + return result; +} + +interface IParsedVersion { + readonly core: readonly [number, number, number]; + readonly prerelease: readonly string[] | undefined; +} + +function parseVersion(version: string): IParsedVersion | undefined { + const match: RegExpMatchArray | null = + /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version); + if (!match) { + return undefined; + } + return { + core: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease: match[4]?.split('.') + }; +} + +function comparePrerelease( + left: readonly string[] | undefined, + right: readonly string[] | undefined +): number { + if (!left) { + return right ? 1 : 0; + } + if (!right) { + return -1; + } + const length: number = Math.max(left.length, right.length); + for (let index: number = 0; index < length; index++) { + const leftPart: string | undefined = left[index]; + const rightPart: string | undefined = right[index]; + if (leftPart === undefined) { + return -1; + } + if (rightPart === undefined) { + return 1; + } + if (leftPart === rightPart) { + continue; + } + const leftNumeric: boolean = /^\d+$/.test(leftPart); + const rightNumeric: boolean = /^\d+$/.test(rightPart); + if (leftNumeric && rightNumeric) { + return Number(leftPart) - Number(rightPart); + } + if (leftNumeric !== rightNumeric) { + return leftNumeric ? -1 : 1; + } + return leftPart < rightPart ? -1 : 1; + } + return 0; +} + +function supportsBootstrapHandoff(rushVersion: string, bootstrapVersion: string): boolean { + const rush: IParsedVersion | undefined = parseVersion(rushVersion); + const bootstrap: IParsedVersion | undefined = parseVersion(bootstrapVersion); + if (!rush || !bootstrap) { + return false; + } + for (let index: number = 0; index < rush.core.length; index++) { + if (rush.core[index] !== bootstrap.core[index]) { + return rush.core[index] > bootstrap.core[index]; + } + } + return comparePrerelease(rush.prerelease, bootstrap.prerelease) >= 0; +} + +function* chunkUtf8Text(text: string, maxChunkBytes: number): Iterable { + let chunkStart: number = 0; + let chunkBytes: number = 0; + let offset: number = 0; + + while (offset < text.length) { + const codePoint: number = text.codePointAt(offset)!; + const codeUnits: number = codePoint > 0xffff ? 2 : 1; + const codePointBytes: number = + codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + if (chunkBytes > 0 && chunkBytes + codePointBytes > maxChunkBytes) { + yield text.slice(chunkStart, offset); + chunkStart = offset; + chunkBytes = 0; + } + chunkBytes += codePointBytes; + offset += codeUnits; + } + + if (chunkStart < text.length) { + yield text.slice(chunkStart); + } +} + +class InstallRunRushBootstrap implements IInstallRunRushBootstrap { + public readonly enabled: boolean = true; + public readonly logger: ILogger; + public readonly externalOutputCaptureMaxBytes: number; + public readonly externalOutputHandler: (stream: BootstrapStream, text: string) => void; + public readonly externalOutputOverflowHandler: () => void; + public readonly prepareToRun: () => void; + + private readonly _entries: IBufferedBootstrapEntry[]; + private readonly _env: Record; + private readonly _stdout: (text: string) => void; + private readonly _stderr: (text: string) => void; + private readonly _handoffDirectory: string; + private readonly _maxBytes: number; + private readonly _now: () => string; + private readonly _randomUUID: () => string; + private readonly _sessionId: string; + private readonly _sourceVersion: string; + private readonly _entryLimit: number; + private _usedBytes: number; + private _nextSequence: number; + private _nextEventNumber: number; + private _droppedReplaceable: number; + private _droppedRequired: number; + private _failureFlushed: boolean; + + public constructor(options: IInstallRunRushBootstrapOptions) { + this._entries = []; + this._env = options.env; + this._stdout = options.stdout ?? ((text: string) => process.stdout.write(text)); + this._stderr = options.stderr ?? ((text: string) => process.stderr.write(text)); + this._handoffDirectory = options.handoffDirectory ?? os.tmpdir(); + this._maxBytes = options.maxBytes ?? BOOTSTRAP_BUFFER_MAX_BYTES; + this._now = options.now ?? (() => new Date().toISOString()); + this._randomUUID = options.randomUUID ?? (() => crypto.randomUUID()); + this._sessionId = `rush_bootstrap_${process.pid}_${this._randomUUID()}`; + this._sourceVersion = options.bootstrapVersion; + this._entryLimit = this._maxBytes - TRUNCATION_NOTICE_RESERVE_BYTES; + if (this._entryLimit <= 0) { + throw new RangeError(`maxBytes must be greater than ${TRUNCATION_NOTICE_RESERVE_BYTES}.`); + } + this._usedBytes = 0; + this._nextSequence = 1; + this._nextEventNumber = 1; + this._droppedReplaceable = 0; + this._droppedRequired = 0; + this._failureFlushed = false; + this.externalOutputCaptureMaxBytes = this._maxBytes; + this._addEvent({ + type: 'sessionStarted', + privacy: 'public', + payload: { rushVersion: options.rushVersion, cwd: process.cwd() } + }); + this._addEvent({ + type: 'commandStarted', + privacy: 'public', + payload: { commandName: options.argv[0] ?? 'unknown', argv: options.argv } + }); + + this.logger = { + info: (text: string) => { + this._addEvent( + { + type: 'activityChanged', + privacy: 'public', + payload: { kind: 'bootstrap', text } + }, + { stream: 'stdout', text: `${text}\n` } + ); + }, + error: (text: string) => { + const droppedRequiredBefore: number = this._droppedRequired; + this._addExternalOutput('stderr', `${text}\n`); + this._flushFailureOutput(); + if (this._droppedRequired > droppedRequiredBefore) { + this._stderr(`${text}\n`); + } + } + }; + this.externalOutputHandler = (stream: BootstrapStream, text: string) => { + this._addExternalOutput(stream, text); + }; + this.externalOutputOverflowHandler = () => { + this._droppedRequired++; + }; + this.prepareToRun = () => { + this._writeHandoff(); + }; + } + + private _addEvent(event: IBootstrapEventInput, fallbackWrite?: IFallbackWrite): void { + const required: boolean = event.type !== 'activityChanged'; + const line: string = encodeBootstrapEnvelope({ + eventId: `boot_${this._nextEventNumber++}`, + sessionId: this._sessionId, + sequence: this._nextSequence++, + timestamp: this._now(), + source: { packageName: 'install-run-rush', packageVersion: this._sourceVersion }, + privacy: event.privacy, + required, + type: event.type, + payload: event.payload + }); + const bytes: number = Buffer.byteLength(line, 'utf8') + 1; + if (this._usedBytes + bytes <= this._entryLimit) { + this._entries.push({ line, bytes, required, fallbackWrite }); + this._usedBytes += bytes; + return; + } + + if (!required) { + this._droppedReplaceable++; + return; + } + + for ( + let index: number = 0; + this._usedBytes + bytes > this._entryLimit && index < this._entries.length; + + ) { + const entry: IBufferedBootstrapEntry = this._entries[index]; + if (entry.required) { + index++; + } else { + this._entries.splice(index, 1); + this._usedBytes -= entry.bytes; + this._droppedReplaceable++; + } + } + if (this._usedBytes + bytes <= this._entryLimit) { + this._entries.push({ line, bytes, required, fallbackWrite }); + this._usedBytes += bytes; + } else { + this._droppedRequired++; + } + } + + private _addExternalOutput(stream: BootstrapStream, text: string): void { + if (!text) { + return; + } + for (const chunk of chunkUtf8Text(text, BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES)) { + this._addEvent( + { + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream, text: chunk } + }, + { stream, text: chunk } + ); + } + } + + private _flushFailureOutput(): void { + if (this._failureFlushed) { + return; + } + this._failureFlushed = true; + for (const entry of this._entries) { + const write: IFallbackWrite | undefined = entry.fallbackWrite; + if (write) { + (write.stream === 'stdout' ? this._stdout : this._stderr)(write.text); + } + } + } + + private _writeHandoff(): void { + const serialized: string = this._serializeEvents(); + const nonce: string = this._randomUUID(); + const fileName: string = `${BOOTSTRAP_HANDOFF_FILE_PREFIX}${process.pid}-${nonce}${BOOTSTRAP_HANDOFF_FILE_SUFFIX}`; + const handoffPath: string = path.join(this._handoffDirectory, fileName); + fs.mkdirSync(this._handoffDirectory, { recursive: true }); + fs.writeFileSync(handoffPath, `${JSON.stringify({ kind: 'bootstrapHandoff', nonce })}\n${serialized}`, { + encoding: 'utf8', + mode: 0o600, + flag: 'wx' + }); + if (process.platform !== 'win32') { + fs.chmodSync(handoffPath, 0o600); + } + this._env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + this._env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + } + + private _serializeEvents(): string { + const truncated: boolean = this._droppedReplaceable + this._droppedRequired > 0; + if (truncated) { + const notice: string = encodeBootstrapEnvelope({ + eventId: 'boot_bufferTruncated', + sessionId: this._sessionId, + sequence: this._nextSequence++, + timestamp: this._now(), + source: { packageName: 'install-run-rush', packageVersion: this._sourceVersion }, + privacy: 'public', + required: true, + type: 'extension', + payload: { + name: BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME, + droppedReplaceable: this._droppedReplaceable, + droppedOther: 0, + droppedRequired: this._droppedRequired, + failed: this._droppedRequired > 0 + } + }); + if (Buffer.byteLength(notice, 'utf8') + 1 > TRUNCATION_NOTICE_RESERVE_BYTES) { + throw new Error('The bootstrap truncation notice exceeded its reserved capacity.'); + } + this._entries.push({ + line: notice, + bytes: Buffer.byteLength(notice, 'utf8') + 1, + required: true + }); + } + + if (this._droppedRequired > 0) { + throw new Error( + `The Rush reporter bootstrap buffer exceeded ${this._maxBytes} bytes and could not preserve ` + + `${this._droppedRequired} required event(s).` + ); + } + + return this._entries.length > 0 + ? `${this._entries.map((entry: IBufferedBootstrapEntry) => entry.line).join('\n')}\n` + : ''; + } +} + +function createLegacyBootstrap(options: IInstallRunRushBootstrapOptions): IInstallRunRushBootstrap { + const stdout: (text: string) => void = options.stdout ?? ((text: string) => process.stdout.write(text)); + const stderr: (text: string) => void = options.stderr ?? ((text: string) => process.stderr.write(text)); + return { + enabled: false, + logger: options.quiet + ? { info: () => {}, error: (text: string) => stderr(`${text}\n`) } + : { + info: (text: string) => stdout(`${text}\n`), + error: (text: string) => stderr(`${text}\n`) + }, + externalOutputHandler: undefined, + externalOutputCaptureMaxBytes: undefined, + externalOutputOverflowHandler: undefined, + prepareToRun: undefined + }; +} + +export function createInstallRunRushBootstrap( + options: IInstallRunRushBootstrapOptions +): IInstallRunRushBootstrap { + if (BOOTSTRAP_PROTOCOL_MAJOR < 1) { + throw new Error('The generated Rush reporter bootstrap protocol is invalid.'); + } + delete options.env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + delete options.env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]; + + if (options.commandName !== 'rush') { + return createLegacyBootstrap(options); + } + + const environmentReporter: string | undefined = options.env.RUSH_REPORTER?.trim().toLowerCase(); + if (environmentReporter === 'legacy') { + return createLegacyBootstrap(options); + } + + const explicitReporter: string | undefined = readSingleFlagValue(options.argv, '--reporter'); + const explicitLogLevel: string | undefined = readSingleFlagValue(options.argv, '--log-level'); + if (explicitReporter !== undefined && !SUPPORTED_REPORTERS.has(explicitReporter)) { + throw new Error( + `Unsupported reporter ${JSON.stringify(explicitReporter)}. ` + + 'Supported values are default, ai, json, plaintext, file, and legacy.' + ); + } + if (explicitLogLevel !== undefined && !SUPPORTED_LOG_LEVELS.has(explicitLogLevel)) { + throw new Error( + `Unsupported log level ${JSON.stringify(explicitLogLevel)}. ` + + 'Supported values are quiet, normal, verbose, and debug.' + ); + } + + if (explicitReporter === 'legacy') { + return createLegacyBootstrap(options); + } + + const repositoryOptIn: boolean = repositoryUsesRushReporter(options.rushJsonFolder); + const explicitOptIn: boolean = explicitReporter !== undefined; + if (!explicitOptIn && !repositoryOptIn) { + return createLegacyBootstrap(options); + } + + if (!supportsBootstrapHandoff(options.rushVersion, options.bootstrapVersion)) { + if (explicitOptIn) { + throw new Error( + `Rush version ${options.rushVersion} does not support the reporter bootstrap requested by ` + + `${JSON.stringify(`--reporter=${explicitReporter}`)}. Update the repository Rush version or ` + + 'use --reporter=legacy.' + ); + } + return createLegacyBootstrap(options); + } + + return new InstallRunRushBootstrap(options); +} diff --git a/libraries/rush-lib/src/scripts/generated/BootstrapProtocol.ts b/libraries/rush-lib/src/scripts/generated/BootstrapProtocol.ts index c30a3a9a38c..5ba8f9e60ba 100644 --- a/libraries/rush-lib/src/scripts/generated/BootstrapProtocol.ts +++ b/libraries/rush-lib/src/scripts/generated/BootstrapProtocol.ts @@ -70,3 +70,48 @@ export function encodeBootstrapEnvelope(input: IBootstrapEnvelopeInput): string payload: input.payload }); } + +/** + * The maximum size of the buffered bootstrap event stream, in bytes (1 MiB). + * + * @beta + */ +export const BOOTSTRAP_BUFFER_MAX_BYTES: number = 1024 * 1024; + +/** + * The maximum size of a single raw external-output chunk, in bytes (64 KiB). + * + * @beta + */ +export const BOOTSTRAP_EXTERNAL_CHUNK_MAX_BYTES: number = 64 * 1024; + +/** + * The private environment variable used to hand the bootstrap NDJSON file path + * to the installed frontend. + * + * @beta + */ +export const RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_HANDOFF' = + '_RUSH_REPORTER_BOOTSTRAP_HANDOFF'; + +/** + * The private environment variable carrying the one-time nonce that must match + * the handoff file's header line. + * + * @remarks + * The nonce proves the handoff file was written by the same bootstrap process + * that set the environment variable: a stale or foreign handoff file (same + * temp directory, different invocation) is rejected rather than replayed. + * + * @beta + */ +export const RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR: '_RUSH_REPORTER_BOOTSTRAP_NONCE' = + '_RUSH_REPORTER_BOOTSTRAP_NONCE'; + +/** + * The namespaced extension event name that describes bootstrap buffer truncation. + * + * @beta + */ +export const BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME: 'rush.reporter.buffer-truncated' = + 'rush.reporter.buffer-truncated'; diff --git a/libraries/rush-lib/src/scripts/install-run-rush.ts b/libraries/rush-lib/src/scripts/install-run-rush.ts index 1bb7b29d0c5..d0fd5eb5edb 100644 --- a/libraries/rush-lib/src/scripts/install-run-rush.ts +++ b/libraries/rush-lib/src/scripts/install-run-rush.ts @@ -7,14 +7,13 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; import type { ILogger } from '../utilities/npmrcUtilities'; +import { createInstallRunRushBootstrap, type IInstallRunRushBootstrap } from './InstallRunRushBootstrap'; import { BOOTSTRAP_PROTOCOL_MAJOR, encodeBootstrapEnvelope } from './generated/BootstrapProtocol'; -const { - installAndRun, - findRushJsonFolder, - RUSH_JSON_FILENAME, - runWithErrorAndStatusCode -}: typeof import('./install-run') = __non_webpack_require__('./install-run'); +const { installAndRun, findRushJsonFolder, RUSH_JSON_FILENAME }: typeof import('./install-run') = + __non_webpack_require__('./install-run'); + +declare const RUSH_LIB_VERSION_FOR_BOOTSTRAP: string; const PACKAGE_NAME: string = '@microsoft/rush'; const RUSH_PREVIEW_VERSION: string = 'RUSH_PREVIEW_VERSION'; @@ -28,11 +27,13 @@ function _validateBundledBootstrapProtocol(): void { } } -function _getRushVersion(logger: ILogger): string { +function _getRushVersion(): { readonly version: string; readonly sourceMessage?: string } { const rushPreviewVersion: string | undefined = process.env[RUSH_PREVIEW_VERSION]; if (rushPreviewVersion !== undefined) { - logger.info(`Using Rush version from environment variable ${RUSH_PREVIEW_VERSION}=${rushPreviewVersion}`); - return rushPreviewVersion; + return { + version: rushPreviewVersion, + sourceMessage: `Using Rush version from environment variable ${RUSH_PREVIEW_VERSION}=${rushPreviewVersion}` + }; } const rushJsonFolder: string = findRushJsonFolder(); @@ -44,7 +45,7 @@ function _getRushVersion(logger: ILogger): string { const rushJsonMatches: string[] = rushJsonContents.match( /\"rushVersion\"\s*\:\s*\"([0-9a-zA-Z.+\-]+)\"/ )!; - return rushJsonMatches[1]; + return { version: rushJsonMatches[1] }; } catch (e) { throw new Error( `Unable to determine the required version of Rush from ${RUSH_JSON_FILENAME} (${rushJsonFolder}). ` + @@ -54,7 +55,7 @@ function _getRushVersion(logger: ILogger): string { } } -function _getBin(scriptName: string): string { +function _getBin(scriptName: string): 'rush' | 'rush-pnpm' | 'rushx' { switch (scriptName.toLowerCase()) { case 'install-run-rush-pnpm.js': return 'rush-pnpm'; @@ -77,7 +78,7 @@ function _run(): void { // Detect if this script was directly invoked, or if the install-run-rushx script was invokved to select the // appropriate binary inside the rush package to run const scriptName: string = path.basename(scriptPath); - const bin: string = _getBin(scriptName); + const bin: 'rush' | 'rush-pnpm' | 'rushx' = _getBin(scriptName); if (!nodePath || !scriptPath) { throw new Error('Unexpected exception: could not detect node path or script path'); } @@ -115,13 +116,25 @@ function _run(): void { process.exit(1); } - const logger: ILogger = quiet - ? { info: () => {}, error: console.error } - : { info: console.log, error: console.error }; - - runWithErrorAndStatusCode(logger, () => { - const version: string = _getRushVersion(logger); - logger.info(`The ${RUSH_JSON_FILENAME} configuration requests Rush version ${version}`); + const rushJsonFolder: string = findRushJsonFolder(); + const rushVersion: { readonly version: string; readonly sourceMessage?: string } = _getRushVersion(); + let bootstrap: IInstallRunRushBootstrap | undefined; + process.exitCode = 1; + try { + bootstrap = createInstallRunRushBootstrap({ + argv: packageBinArgs, + env: process.env, + rushJsonFolder, + rushVersion: rushVersion.version, + bootstrapVersion: RUSH_LIB_VERSION_FOR_BOOTSTRAP, + commandName: bin, + quiet + }); + const logger: ILogger = bootstrap.logger; + if (rushVersion.sourceMessage) { + logger.info(rushVersion.sourceMessage); + } + logger.info(`The ${RUSH_JSON_FILENAME} configuration requests Rush version ${rushVersion.version}`); const lockFilePath: string | undefined = process.env[INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE]; if (lockFilePath) { @@ -130,8 +143,26 @@ function _run(): void { ); } - return installAndRun(logger, PACKAGE_NAME, version, bin, packageBinArgs, lockFilePath); - }); + process.exitCode = installAndRun( + logger, + PACKAGE_NAME, + rushVersion.version, + bin, + packageBinArgs, + lockFilePath, + { + onExternalOutput: bootstrap.externalOutputHandler, + onExternalOutputOverflow: bootstrap.externalOutputOverflowHandler, + externalOutputCaptureMaxBytes: bootstrap.externalOutputCaptureMaxBytes, + prepareToRun: bootstrap.prepareToRun + } + ); + } catch (error) { + const logger: ILogger = + bootstrap?.logger ?? + (quiet ? { info: () => {}, error: console.error } : { info: console.log, error: console.error }); + logger.error(`\n\n${String(error)}\n`); + } } _run(); diff --git a/libraries/rush-lib/src/scripts/install-run.ts b/libraries/rush-lib/src/scripts/install-run.ts index 7f568566485..84e350938a0 100644 --- a/libraries/rush-lib/src/scripts/install-run.ts +++ b/libraries/rush-lib/src/scripts/install-run.ts @@ -7,6 +7,7 @@ import * as childProcess from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { StringDecoder } from 'node:string_decoder'; import type { IPackageJson } from '@rushstack/node-core-library'; @@ -20,6 +21,61 @@ const INSTALL_RUN_LOCKFILE_PATH_VARIABLE: 'INSTALL_RUN_LOCKFILE_PATH' = 'INSTALL const INSTALLED_FLAG_FILENAME: string = 'installed.flag'; const NODE_MODULES_FOLDER_NAME: string = 'node_modules'; const PACKAGE_JSON_FILENAME: string = 'package.json'; +let _externalOutputCaptureId: number = 0; +const NPM_OUTPUT_CAPTURE_SCRIPT: string = ` +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const { StringDecoder } = require('node:string_decoder'); +const [command, argsJson, capturePath, useShell, maxBytesText] = process.argv.slice(1); +const child = childProcess.spawn(command, JSON.parse(argsJson), { + cwd: process.cwd(), + env: process.env, + shell: useShell === '1', + windowsVerbatimArguments: false, + stdio: ['inherit', 'pipe', 'pipe'] +}); +const decoders = { stdout: new StringDecoder('utf8'), stderr: new StringDecoder('utf8') }; +const maxBytes = Number(maxBytesText); +let capturedBytes = 0; +let overflowed = false; +function capture(stream, text) { + if (!text || overflowed) { + return; + } + const record = JSON.stringify({ stream, text }) + '\\n'; + const recordBytes = Buffer.byteLength(record); + if (capturedBytes + recordBytes <= maxBytes) { + fs.appendFileSync(capturePath, record); + capturedBytes += recordBytes; + } else { + overflowed = true; + fs.appendFileSync(capturePath, JSON.stringify({ overflow: true }) + '\\n'); + } +} +child.stdout.on('data', (chunk) => capture('stdout', decoders.stdout.write(chunk))); +child.stderr.on('data', (chunk) => capture('stderr', decoders.stderr.write(chunk))); +child.on('error', (error) => { + process.stderr.write(String(error) + '\\n'); + process.exitCode = 1; +}); +child.on('close', (code, signal) => { + capture('stdout', decoders.stdout.end()); + capture('stderr', decoders.stderr.end()); + if (signal) { + process.stderr.write('npm was terminated by signal: ' + signal + '\\n'); + process.exitCode = 1; + } else { + process.exitCode = code === null ? 1 : code; + } +}); +`; + +export interface IInstallAndRunOptions { + readonly onExternalOutput?: (stream: 'stdout' | 'stderr', text: string) => void; + readonly onExternalOutputOverflow?: () => void; + readonly externalOutputCaptureMaxBytes?: number; + readonly prepareToRun?: () => void; +} /** * Parse a package specifier (in the form of name\@version) into name and version parts. @@ -352,22 +408,102 @@ function _installPackage( packageInstallFolder: string, name: string, version: string, - npmCommand: 'install' | 'ci' + npmCommand: 'install' | 'ci', + onExternalOutput: ((stream: 'stdout' | 'stderr', text: string) => void) | undefined, + onExternalOutputOverflow: (() => void) | undefined, + externalOutputCaptureMaxBytes: number | undefined ): void { + let capturePath: string | undefined; try { logger.info(`Installing ${name}...`); - _runNpmConfirmSuccess( - [npmCommand], - { - stdio: 'inherit', - cwd: packageInstallFolder, - env: process.env - }, - `npm ${npmCommand}` - ); - logger.info(`Successfully installed ${name}@${version}`); + if (onExternalOutput) { + capturePath = path.join( + packageInstallFolder, + `.install-run-output-${process.pid}-${_externalOutputCaptureId++}.log` + ); + fs.closeSync(fs.openSync(capturePath, 'wx', 0o600)); + } + if (capturePath) { + _runNpmWithCaptureConfirmSuccess( + [npmCommand], + { + stdio: 'inherit', + cwd: packageInstallFolder, + env: process.env + }, + capturePath, + externalOutputCaptureMaxBytes ?? 1024 * 1024, + `npm ${npmCommand}` + ); + } else { + _runNpmConfirmSuccess( + [npmCommand], + { + stdio: 'inherit', + cwd: packageInstallFolder, + env: process.env + }, + `npm ${npmCommand}` + ); + } } catch (e) { throw new Error(`Unable to install package: ${e}`); + } finally { + if (capturePath !== undefined) { + try { + _readCapturedNpmOutput(capturePath, onExternalOutput!, onExternalOutputOverflow); + } finally { + _deleteFile(capturePath); + } + } + } + logger.info(`Successfully installed ${name}@${version}`); +} + +function _readCapturedNpmOutput( + capturePath: string, + onExternalOutput: (stream: 'stdout' | 'stderr', text: string) => void, + onExternalOutputOverflow: (() => void) | undefined +): void { + const fileDescriptor: number = fs.openSync(capturePath, 'r'); + const buffer: Buffer = Buffer.allocUnsafe(64 * 1024); + const decoder: StringDecoder = new StringDecoder('utf8'); + let pending: string = ''; + try { + for (;;) { + const bytesRead: number = fs.readSync(fileDescriptor, buffer, 0, buffer.length, null); + if (bytesRead === 0) { + break; + } + pending += decoder.write(buffer.subarray(0, bytesRead)); + let newlineIndex: number; + while ((newlineIndex = pending.indexOf('\n')) >= 0) { + const line: string = pending.slice(0, newlineIndex); + pending = pending.slice(newlineIndex + 1); + if (line) { + const record: { stream?: unknown; text?: unknown; overflow?: unknown } = JSON.parse(line); + if (record.overflow === true) { + onExternalOutputOverflow?.(); + } else if ( + (record.stream === 'stdout' || record.stream === 'stderr') && + typeof record.text === 'string' + ) { + onExternalOutput(record.stream, record.text); + } + } + } + } + pending += decoder.end(); + if (pending.trim()) { + const record: { overflow?: unknown } = JSON.parse(pending); + if (record.overflow === true) { + onExternalOutputOverflow?.(); + } else { + throw new Error('The npm output capture ended with an incomplete record.'); + } + } + } finally { + fs.closeSync(fileDescriptor); } } @@ -417,7 +553,41 @@ function _runNpmConfirmSuccess( } else { result = childProcess.spawnSync(command, args, options); } + _throwIfSpawnFailed(result, commandNameForLogging); + return result; +} +function _runNpmWithCaptureConfirmSuccess( + args: string[], + options: childProcess.SpawnSyncOptions, + capturePath: string, + captureMaxBytes: number, + commandNameForLogging: string +): childProcess.SpawnSyncReturns { + const npmPath: string = getNpmPath(); + const command: string = IS_WINDOWS ? _buildShellCommand(npmPath, args) : npmPath; + const commandArgs: string[] = IS_WINDOWS ? [] : args; + const result: childProcess.SpawnSyncReturns = childProcess.spawnSync( + process.execPath, + [ + '-e', + NPM_OUTPUT_CAPTURE_SCRIPT, + command, + JSON.stringify(commandArgs), + capturePath, + IS_WINDOWS ? '1' : '0', + String(captureMaxBytes) + ], + options + ); + _throwIfSpawnFailed(result, commandNameForLogging); + return result; +} + +function _throwIfSpawnFailed( + result: childProcess.SpawnSyncReturns, + commandNameForLogging: string +): void { if (result.status !== 0) { if (!result.status) { // Is status null or undefined? @@ -432,8 +602,6 @@ function _runNpmConfirmSuccess( throw new Error(`"${commandNameForLogging}" returned error code ${result.status}`); } } - - return result; } export function installAndRun( @@ -442,7 +610,8 @@ export function installAndRun( packageVersion: string, packageBinName: string, packageBinArgs: string[], - lockFilePath: string | undefined = process.env[INSTALL_RUN_LOCKFILE_PATH_VARIABLE] + lockFilePath: string | undefined = process.env[INSTALL_RUN_LOCKFILE_PATH_VARIABLE], + options: IInstallAndRunOptions = {} ): number { const rushJsonFolder: string = findRushJsonFolder(); const rushCommonFolder: string = path.join(rushJsonFolder, 'common'); @@ -470,13 +639,23 @@ export function installAndRun( _createPackageJson(packageInstallFolder, packageName, packageVersion); const installCommand: 'install' | 'ci' = lockFilePath ? 'ci' : 'install'; - _installPackage(logger, packageInstallFolder, packageName, packageVersion, installCommand); + _installPackage( + logger, + packageInstallFolder, + packageName, + packageVersion, + installCommand, + options.onExternalOutput, + options.onExternalOutputOverflow, + options.externalOutputCaptureMaxBytes + ); _writeFlagFile(packageInstallFolder); } const statusMessage: string = `Invoking "${packageBinName} ${packageBinArgs.join(' ')}"`; const statusMessageLine: string = new Array(statusMessage.length + 1).join('-'); logger.info('\n' + statusMessage + '\n' + statusMessageLine + '\n'); + options.prepareToRun?.(); const binPath: string = _getBinPath(packageInstallFolder, packageBinName); const binFolderPath: string = path.resolve(packageInstallFolder, NODE_MODULES_FOLDER_NAME, '.bin'); diff --git a/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts new file mode 100644 index 00000000000..443d63a14c7 --- /dev/null +++ b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts @@ -0,0 +1,261 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { + createInstallRunRushBootstrap, + type IInstallRunRushBootstrap, + type IInstallRunRushBootstrapOptions +} from '../InstallRunRushBootstrap'; +import { + BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME, + RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, + RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR +} from '../generated/BootstrapProtocol'; + +async function withTempDir(action: (directory: string) => Promise): Promise { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install-run-rush-test-')); + try { + await action(directory); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } +} + +function makeOptions( + directory: string, + overrides: Partial = {} +): { + readonly options: IInstallRunRushBootstrapOptions; + readonly env: Record; + readonly stdout: string[]; + readonly stderr: string[]; +} { + const env: Record = {}; + const stdout: string[] = []; + const stderr: string[] = []; + return { + env, + stdout, + stderr, + options: { + argv: ['build'], + env, + rushJsonFolder: directory, + rushVersion: '5.178.1', + bootstrapVersion: '5.178.1', + commandName: 'rush', + quiet: false, + stdout: (text: string) => stdout.push(text), + stderr: (text: string) => stderr.push(text), + handoffDirectory: directory, + now: () => '2026-08-28T00:00:00.000Z', + randomUUID: (() => { + let index: number = 0; + return () => `00000000-0000-4000-8000-${String(++index).padStart(12, '0')}`; + })(), + ...overrides + } + }; +} + +function readHandoff(env: Record): { + readonly path: string; + readonly records: Record[]; +} { + const handoffPath: string | undefined = env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + if (!handoffPath) { + throw new Error('Expected a bootstrap handoff path.'); + } + const records: Record[] = fs + .readFileSync(handoffPath, 'utf8') + .trim() + .split('\n') + .map((line: string) => JSON.parse(line) as Record); + return { path: handoffPath, records }; +} + +describe(createInstallRunRushBootstrap.name, () => { + it('preserves direct legacy bootstrap output without an opt-in', async () => { + await withTempDir(async (directory: string) => { + const { options, env, stdout } = makeOptions(directory); + env.RUSH_REPORTER = 'unsupported-automatic-value'; + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + + bootstrap.logger.info('legacy startup'); + bootstrap.prepareToRun?.(); + + expect(bootstrap.enabled).toBe(false); + expect(stdout).toEqual(['legacy startup\n']); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBeUndefined(); + }); + }); + + it('writes an ordered nonce-protected handoff for an explicit reporter', async () => { + await withTempDir(async (directory: string) => { + const { options, env, stdout } = makeOptions(directory, { + argv: ['build', '--reporter=json', '--log-level=debug'] + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + + bootstrap.logger.info('resolving Rush'); + bootstrap.externalOutputHandler?.('stdout', 'npm line 1\nnpm line 2\n'); + bootstrap.logger.info('invoking Rush'); + bootstrap.prepareToRun?.(); + + const handoff = readHandoff(env); + expect(bootstrap.enabled).toBe(true); + expect(stdout).toEqual([]); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBe( + (handoff.records[0] as { nonce?: string }).nonce + ); + expect(handoff.records.slice(1).map((record: Record) => record.type)).toEqual([ + 'sessionStarted', + 'commandStarted', + 'activityChanged', + 'externalOutput', + 'activityChanged' + ]); + expect((handoff.records[4].payload as { text: string }).text).toBe('npm line 1\nnpm line 2\n'); + if (process.platform !== 'win32') { + expect(fs.statSync(handoff.path).mode % 0o1000).toBe(0o600); + } + }); + }); + + it('uses repository opt-in but safely falls back for an old frontend', async () => { + await withTempDir(async (directory: string) => { + const experimentsFolder: string = path.join(directory, 'common', 'config', 'rush'); + await fs.promises.mkdir(experimentsFolder, { recursive: true }); + await fs.promises.writeFile( + path.join(experimentsFolder, 'experiments.json'), + '{ "useRushReporter": true }\n' + ); + const { options, stdout } = makeOptions(directory, { rushVersion: '5.177.0' }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.logger.info('old frontend startup'); + + expect(bootstrap.enabled).toBe(false); + expect(stdout).toEqual(['old frontend startup\n']); + }); + }); + + it('ignores a commented hypothetical repository opt-in', async () => { + await withTempDir(async (directory: string) => { + const experimentsFolder: string = path.join(directory, 'common', 'config', 'rush'); + await fs.promises.mkdir(experimentsFolder, { recursive: true }); + await fs.promises.writeFile( + path.join(experimentsFolder, 'experiments.json'), + [ + '{', + ' // "useRushReporter": true,', + ' "exampleUrl": "https://example.test/*not-a-comment*/"', + '}' + ].join('\n') + ); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap( + makeOptions(directory).options + ); + + expect(bootstrap.enabled).toBe(false); + }); + }); + + it('does not treat an older prerelease of the bootstrap version as compatible', async () => { + await withTempDir(async (directory: string) => { + expect(() => + createInstallRunRushBootstrap( + makeOptions(directory, { + argv: ['build', '--reporter=json'], + rushVersion: '5.178.1-dev.1', + bootstrapVersion: '5.178.1-dev.10' + }).options + ) + ).toThrow(/does not support the reporter bootstrap/); + }); + }); + + it('fails unsupported explicit requests and explicit requests for an old frontend', async () => { + await withTempDir(async (directory: string) => { + expect(() => + createInstallRunRushBootstrap( + makeOptions(directory, { argv: ['build', '--reporter=unknown'] }).options + ) + ).toThrow(/Unsupported reporter/); + expect(() => + createInstallRunRushBootstrap( + makeOptions(directory, { + argv: ['build', '--reporter=json'], + rushVersion: '5.177.0' + }).options + ) + ).toThrow(/does not support the reporter bootstrap/); + }); + }); + + it('honors the legacy emergency override before validating reporter controls', async () => { + await withTempDir(async (directory: string) => { + const { options, env } = makeOptions(directory, { + argv: ['build', '--reporter=unknown', '--log-level=invalid'] + }); + env.RUSH_REPORTER = ' LEGACY '; + + expect(createInstallRunRushBootstrap(options).enabled).toBe(false); + }); + }); + + it('truncates replaceable startup status with a required marker', async () => { + await withTempDir(async (directory: string) => { + const { options, env } = makeOptions(directory, { + argv: ['build', '--reporter=json'], + maxBytes: 1800 + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + for (let index: number = 0; index < 50; index++) { + bootstrap.logger.info(`status ${index} ${'x'.repeat(80)}`); + } + bootstrap.prepareToRun?.(); + + const handoff = readHandoff(env); + const eventRecords: Record[] = handoff.records.slice(1); + const marker: Record = eventRecords[eventRecords.length - 1]; + expect(marker.type).toBe('extension'); + expect((marker.payload as { name: string }).name).toBe(BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME); + expect((marker.payload as { droppedReplaceable: number }).droppedReplaceable).toBeGreaterThan(0); + expect( + Buffer.byteLength(fs.readFileSync(handoff.path, 'utf8').split('\n').slice(1).join('\n'), 'utf8') + ).toBeLessThanOrEqual(1800); + }); + }); + + it('fails instead of dropping required external output', async () => { + await withTempDir(async (directory: string) => { + const { options, env, stderr } = makeOptions(directory, { + argv: ['build', '--reporter=json'], + maxBytes: 800 + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.externalOutputHandler?.('stdout', 'x'.repeat(2000)); + + expect(() => bootstrap.prepareToRun?.()).toThrow(/could not preserve/); + bootstrap.logger.error('bootstrap failed'); + + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(stderr.join('')).toContain('bootstrap failed'); + }); + }); + + it('fails when the npm capture reports overflow before replay', async () => { + await withTempDir(async (directory: string) => { + const { options } = makeOptions(directory, { argv: ['build', '--reporter=json'] }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.externalOutputOverflowHandler?.(); + + expect(() => bootstrap.prepareToRun?.()).toThrow(/could not preserve/); + }); + }); +}); diff --git a/libraries/rush-lib/webpack.config.js b/libraries/rush-lib/webpack.config.js index 6952beba9dc..fd758205ace 100644 --- a/libraries/rush-lib/webpack.config.js +++ b/libraries/rush-lib/webpack.config.js @@ -116,32 +116,39 @@ module.exports = () => { } } ), - generateConfiguration({ - [PathConstants.pnpmfileShimFilename]: { - import: `${__dirname}/lib-intermediate-esm/logic/pnpm/PnpmfileShim.js`, - ...SCRIPT_ENTRY_OPTIONS - }, - [PathConstants.subspacePnpmfileShimFilename]: { - import: `${__dirname}/lib-intermediate-esm/logic/pnpm/SubspaceGlobalPnpmfileShim.js`, - ...SCRIPT_ENTRY_OPTIONS - }, - [PathConstants.installRunScriptFilename]: { - import: `${__dirname}/lib-intermediate-esm/scripts/install-run.js`, - ...SCRIPT_ENTRY_OPTIONS - }, - [PathConstants.installRunRushScriptFilename]: { - import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rush.js`, - ...SCRIPT_ENTRY_OPTIONS - }, - [PathConstants.installRunRushxScriptFilename]: { - import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rushx.js`, - ...SCRIPT_ENTRY_OPTIONS + generateConfiguration( + { + [PathConstants.pnpmfileShimFilename]: { + import: `${__dirname}/lib-intermediate-esm/logic/pnpm/PnpmfileShim.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.subspacePnpmfileShimFilename]: { + import: `${__dirname}/lib-intermediate-esm/logic/pnpm/SubspaceGlobalPnpmfileShim.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.installRunScriptFilename]: { + import: `${__dirname}/lib-intermediate-esm/scripts/install-run.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.installRunRushScriptFilename]: { + import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rush.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.installRunRushxScriptFilename]: { + import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rushx.js`, + ...SCRIPT_ENTRY_OPTIONS + }, + [PathConstants.installRunRushPnpmScriptFilename]: { + import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rush-pnpm.js`, + ...SCRIPT_ENTRY_OPTIONS + } }, - [PathConstants.installRunRushPnpmScriptFilename]: { - import: `${__dirname}/lib-intermediate-esm/scripts/install-run-rush-pnpm.js`, - ...SCRIPT_ENTRY_OPTIONS - } - }) + [ + new webpack.DefinePlugin({ + RUSH_LIB_VERSION_FOR_BOOTSTRAP: JSON.stringify(packageJson.version) + }) + ] + ) ]; return configurations; From 993547edbb3e001499e874266a0b9bfe7d7da7f8 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 05:52:39 +0000 Subject: [PATCH 2/5] Fix reporter bootstrap compatibility failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/RushCommandSelector.ts | 12 +- apps/rush/src/RushReporterHost.ts | 154 ++++++++++-------- .../rush/src/test/RushCommandSelector.test.ts | 55 ++++++- apps/rush/src/test/RushReporterHost.test.ts | 127 +++++++++++++++ common/reviews/api/rush-reporter.api.md | 3 +- .../reporter/src/frontend/ReporterHost.ts | 33 +++- .../reporter/src/test/ReporterHost.test.ts | 40 ++++- 7 files changed, 342 insertions(+), 82 deletions(-) diff --git a/apps/rush/src/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index 0453811b4de..f486016b4da 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -5,6 +5,7 @@ import * as path from 'node:path'; import { StringDecoder } from 'node:string_decoder'; import { + LegacyFallbackSink, OldEngineOutputAdapter, REPORTER_PROTOCOL_VERSION, resolveReporterCompatibility, @@ -56,13 +57,7 @@ export class RushCommandSelector { } ); let effectiveOptions: IRushFrontendLaunchOptions = options; - if (compatibility.mode === 'new-frontend-old-engine' && options.reporterEnabled) { - _observeOldEngineOutput(options, Rush.version); - } else if ( - compatibility.mode === 'old-frontend-new-engine' && - engineProtocolMajor !== undefined && - options.reporterEnabled - ) { + if (compatibility.mode !== 'structured' && engineProtocolMajor !== undefined && options.reporterEnabled) { if (options.reporterSelectionReason === 'explicit --reporter') { throw new Error( `The selected Rush engine uses reporter protocol major ${engineProtocolMajor}, but this ` + @@ -72,9 +67,12 @@ export class RushCommandSelector { } effectiveOptions = { ...options, + reporterEventSink: new LegacyFallbackSink(), reporterEnabled: false, reporterSelectionReason: 'bootstrap compatibility fallback' }; + } else if (compatibility.mode === 'new-frontend-old-engine' && options.reporterEnabled) { + _observeOldEngineOutput(options, Rush.version); } if (commandName === 'rush-pnpm') { diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index 0163b3b16aa..2944e364fb7 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -624,92 +624,104 @@ export async function initializeRushReporterHostAsync( columns: process.stderr.columns, write: process.stderr.write.bind(process.stderr) }; - let selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); const host: ReporterHost = new ReporterHost({ env, handoffDirectory: options.handoffDirectory, retentionMs: options.handoffRetentionMs, nowMs: options.nowMs }); + let handoffReplayAttempted: boolean = false; + let closePromise: Promise | undefined; - if (selection.enabled) { - const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); - if (primaryReporter) { - host.manager.addReporter(new LogLevelReporter(primaryReporter, selection.logLevel), { - destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' - }); + try { + let selection: IRushReporterSelection = resolveRushReporterSelection({ ...options, env, stdout }); + + if (selection.enabled) { + const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); + if (primaryReporter) { + host.manager.addReporter(new LogLevelReporter(primaryReporter, selection.logLevel), { + destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' + }); + } + + const hasExplicitFileOutput: boolean = selection.outputs.some( + (output: IReporterOutputTarget) => output.reporter === 'file' + ); + if ( + options.includeDefaultFileReporter !== false && + selection.reporter !== 'file' && + !hasExplicitFileOutput + ) { + host.manager.addReporter(new FileReporter(), { destination: 'file:auto' }); + } + + for (const output of selection.outputs) { + const outputLogLevel: ReporterLogLevel = + output.params.logLevel && isSupportedLogLevel(output.params.logLevel) + ? output.params.logLevel + : output.reporter === 'file' + ? 'debug' + : selection.logLevel; + host.manager.addReporter(new ExplicitOutputReporter(output.reporter, output.target, outputLogLevel), { + destination: output.target + }); + } } - const hasExplicitFileOutput: boolean = selection.outputs.some( - (output: IReporterOutputTarget) => output.reporter === 'file' - ); + await host.manager.initializeAsync(); + const bootstrapReplay: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); + handoffReplayAttempted = true; + const abandonedHandoffFilesDeleted: readonly string[] = await host.cleanAbandonedHandoffFilesAsync(); + + let sink: IReporterEventSink = host.getSink(); if ( - options.includeDefaultFileReporter !== false && - selection.reporter !== 'file' && - !hasExplicitFileOutput + bootstrapReplay.skipReason === 'incompatible-protocol' || + bootstrapReplay.skipReason === 'unsupported-required-event' ) { - host.manager.addReporter(new FileReporter(), { destination: 'file:auto' }); - } - - for (const output of selection.outputs) { - const outputLogLevel: ReporterLogLevel = - output.params.logLevel && isSupportedLogLevel(output.params.logLevel) - ? output.params.logLevel - : output.reporter === 'file' - ? 'debug' - : selection.logLevel; - host.manager.addReporter(new ExplicitOutputReporter(output.reporter, output.target, outputLogLevel), { - destination: output.target - }); + for (const output of bootstrapReplay.legacyFallbackOutput ?? []) { + const target: IRushReporterOutputStream = + selection.reason === 'explicit --reporter' ? stderr : output.stream === 'stdout' ? stdout : stderr; + target.write(output.text); + } + if (selection.reason === 'explicit --reporter') { + const incompatibility: string = + bootstrapReplay.skipReason === 'incompatible-protocol' + ? 'protocol is incompatible' + : 'contains an unsupported required event'; + throw new Error( + `The install-run-rush bootstrap reporter ${incompatibility} with this Rush frontend. ` + + 'Update the global Rush installation or use --reporter=legacy.' + ); + } + selection = { + reporter: 'legacy', + logLevel: 'normal', + outputs: [], + commandJson: selection.commandJson, + enabled: false, + reporterControlsOwnedByFrontend: selection.reporterControlsOwnedByFrontend, + reporterValueFlagsToStrip: selection.reporterValueFlagsToStrip, + reason: 'bootstrap compatibility fallback' + }; + sink = new LegacyFallbackSink(); } - } - await host.manager.initializeAsync(); - let bootstrapReplay: IBootstrapReplayResult; - try { - bootstrapReplay = await host.replayBootstrapHandoffAsync(); + return { + host, + sink, + selection, + bootstrapReplay, + abandonedHandoffFilesDeleted, + closeAsync: (timeoutMs?: number) => { + closePromise ??= host.manager.closeAsync(timeoutMs); + return closePromise; + } + }; } finally { + if (!handoffReplayAttempted) { + await host.discardBootstrapHandoffAsync(); + } delete env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; delete env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]; } - const abandonedHandoffFilesDeleted: readonly string[] = await host.cleanAbandonedHandoffFilesAsync(); - - let sink: IReporterEventSink = host.getSink(); - if (bootstrapReplay.skipReason === 'incompatible-protocol') { - for (const output of bootstrapReplay.legacyFallbackOutput ?? []) { - const target: IRushReporterOutputStream = - selection.reason === 'explicit --reporter' ? stderr : output.stream === 'stdout' ? stdout : stderr; - target.write(output.text); - } - if (selection.reason === 'explicit --reporter') { - throw new Error( - 'The install-run-rush bootstrap reporter protocol is incompatible with this Rush frontend. ' + - 'Update the global Rush installation or use --reporter=legacy.' - ); - } - selection = { - reporter: 'legacy', - logLevel: 'normal', - outputs: [], - commandJson: selection.commandJson, - enabled: false, - reporterControlsOwnedByFrontend: selection.reporterControlsOwnedByFrontend, - reporterValueFlagsToStrip: selection.reporterValueFlagsToStrip, - reason: 'bootstrap compatibility fallback' - }; - sink = new LegacyFallbackSink(); - } - - let closePromise: Promise | undefined; - return { - host, - sink, - selection, - bootstrapReplay, - abandonedHandoffFilesDeleted, - closeAsync: (timeoutMs?: number) => { - closePromise ??= host.manager.closeAsync(timeoutMs); - return closePromise; - } - }; } diff --git a/apps/rush/src/test/RushCommandSelector.test.ts b/apps/rush/src/test/RushCommandSelector.test.ts index 3ec4e45375a..1db9f261068 100644 --- a/apps/rush/src/test/RushCommandSelector.test.ts +++ b/apps/rush/src/test/RushCommandSelector.test.ts @@ -1,7 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { ReporterManager, type IReporter, type IReporterEventEnvelope } from '@rushstack/rush-reporter'; +import { + LegacyFallbackSink, + ReporterManager, + type IReporter, + type IReporterEventEnvelope +} from '@rushstack/rush-reporter'; import { RushCommandSelector } from '../RushCommandSelector'; import type { IRushFrontendLaunchOptions } from '../IRushFrontendLaunchOptions'; @@ -145,6 +150,26 @@ describe(RushCommandSelector.name, () => { ); }); + it('fails an explicit reporter request for an incompatible older engine protocol', () => { + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporterEventSink: new ReporterManager(), + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + }; + const incompatibleRushLib = { + Rush: { + version: '5.177.0', + _reporterProtocolMajor: 0, + launch: () => undefined + } + } as unknown as typeof import('@microsoft/rush-lib'); + + expect(() => RushCommandSelector.execute('5.178.1', incompatibleRushLib, options)).toThrow( + /reporter protocol major 0/ + ); + }); + it('falls back to legacy engine rendering for an implicit incompatible protocol', () => { let receivedOptions: IRushFrontendLaunchOptions | undefined; const options: IRushFrontendLaunchOptions = { @@ -169,5 +194,33 @@ describe(RushCommandSelector.name, () => { reporterEnabled: false, reporterSelectionReason: 'bootstrap compatibility fallback' }); + expect(receivedOptions?.reporterEventSink).toBeInstanceOf(LegacyFallbackSink); + }); + + it('falls back to legacy engine rendering for an implicit older protocol', () => { + let receivedOptions: IRushFrontendLaunchOptions | undefined; + const options: IRushFrontendLaunchOptions = { + isManaged: true, + reporterEventSink: new ReporterManager(), + reporterEnabled: true, + reporterSelectionReason: 'repository experiment' + }; + const incompatibleRushLib = { + Rush: { + version: '5.177.0', + _reporterProtocolMajor: 0, + launch: (launcherVersion: string, launchOptions: IRushFrontendLaunchOptions) => { + void launcherVersion; + receivedOptions = launchOptions; + } + } + } as unknown as typeof import('@microsoft/rush-lib'); + + RushCommandSelector.execute('5.178.1', incompatibleRushLib, options); + expect(receivedOptions).toMatchObject({ + reporterEnabled: false, + reporterSelectionReason: 'bootstrap compatibility fallback' + }); + expect(receivedOptions?.reporterEventSink).toBeInstanceOf(LegacyFallbackSink); }); }); diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index 77b3bf1a6a1..fe9d32531cc 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -547,4 +547,131 @@ describe(initializeRushReporterHostAsync.name, () => { await fs.promises.rm(directory, { recursive: true, force: true }); } }); + + it('falls back when repository opt-in meets an unsupported required bootstrap event', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.addExternalOutput('stdout', 'npm output\n'); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const lines: string[] = (await fs.promises.readFile(handoffPath, 'utf8')).trimEnd().split('\n'); + const requiredEvent: Record = { + ...(JSON.parse(lines[1]) as Record), + eventId: 'future-required', + type: 'futureRequiredEvent', + required: true, + protocolVersion: { major: 1, minor: 1 } + }; + lines.push(JSON.stringify(requiredEvent)); + await fs.promises.writeFile(handoffPath, `${lines.join('\n')}\n`); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: ['build'], + env, + repositoryOptIn: true, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + + expect(initialized.bootstrapReplay.skipReason).toBe('unsupported-required-event'); + expect(initialized.selection).toMatchObject({ + enabled: false, + reason: 'bootstrap compatibility fallback' + }); + expect(stdoutText).toBe('npm output\n'); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('fails an explicit reporter request for an unsupported required bootstrap event', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stderrText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.addExternalOutput('stdout', 'npm output\n'); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const lines: string[] = (await fs.promises.readFile(handoffPath, 'utf8')).trimEnd().split('\n'); + const requiredEvent: Record = { + ...(JSON.parse(lines[1]) as Record), + eventId: 'future-required', + type: 'futureRequiredEvent', + required: true, + protocolVersion: { major: 1, minor: 1 } + }; + lines.push(JSON.stringify(requiredEvent)); + await fs.promises.writeFile(handoffPath, `${lines.join('\n')}\n`); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + await expect( + initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json'], + env, + handoffDirectory: directory, + stdout: { isTTY: false, write: () => undefined }, + stderr: { + write: (text: string) => { + stderrText += text; + } + }, + includeDefaultFileReporter: false + }) + ).rejects.toThrow(/unsupported required event/); + + expect(stderrText).toBe('npm output\n'); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('deletes an authenticated handoff when explicit reporter validation fails', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ type: 'sessionStarted', payload: {} }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + await expect( + initializeRushReporterHostAsync({ + argv: ['build', '--reporter=default'], + env, + handoffDirectory: directory, + stdout: { isTTY: false, write: () => undefined }, + includeDefaultFileReporter: false + }) + ).rejects.toThrow(/requires an interactive TTY/); + + expect(fs.existsSync(handoffPath)).toBe(false); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBeUndefined(); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); }); diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 342b21b8754..3865d31aefc 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -344,7 +344,7 @@ export interface IBootstrapReplayResult { readonly legacyFallbackOutput?: readonly IBootstrapLegacyOutput[]; readonly replayed: boolean; readonly skippedEventCount?: number; - readonly skipReason?: 'unreadable' | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' | 'incompatible-protocol'; + readonly skipReason?: 'unreadable' | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' | 'unsupported-required-event' | 'incompatible-protocol'; } // @beta @@ -1373,6 +1373,7 @@ export type ReporterExtensionEventName = `${string}.${string}` & { export class ReporterHost { constructor(options?: IReporterHostOptions); cleanAbandonedHandoffFilesAsync(): Promise; + discardBootstrapHandoffAsync(): Promise; getSink(): IReporterEventSink; get manager(): ReporterManager; replayBootstrapHandoffAsync(): Promise; diff --git a/libraries/reporter/src/frontend/ReporterHost.ts b/libraries/reporter/src/frontend/ReporterHost.ts index 770986282d0..7f1c4fa19c5 100644 --- a/libraries/reporter/src/frontend/ReporterHost.ts +++ b/libraries/reporter/src/frontend/ReporterHost.ts @@ -103,6 +103,7 @@ export interface IBootstrapReplayResult { | 'invalid-path' | 'nonce-mismatch' | 'invalid-event' + | 'unsupported-required-event' | 'incompatible-protocol'; /** @@ -305,13 +306,15 @@ export class ReporterHost { } if (!isReporterEventEnvelope(event)) { if (isRecord(event) && event.required === true) { + const legacyFallbackOutput: IBootstrapLegacyOutput[] = getLegacyFallbackOutput(events); await deleteBootstrapHandoffFileAsync(handoffPath); return { direct: false, replayed: false, eventCount: 0, handoffPath, - skipReason: 'invalid-event' + skipReason: 'unsupported-required-event', + ...(legacyFallbackOutput.length > 0 ? { legacyFallbackOutput } : {}) }; } skippedEventCount++; @@ -348,6 +351,34 @@ export class ReporterHost { }; } + /** + * Deletes the current authenticated bootstrap handoff without replaying it. + * + * @remarks + * This is used when frontend initialization fails before replay can begin. + * Paths outside the configured handoff directory and nonce mismatches are + * rejected without deleting the referenced file. + * + */ + public async discardBootstrapHandoffAsync(): Promise { + const handoffPath: string | undefined = this._env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]; + const expectedNonce: string | undefined = this._env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]; + if (!handoffPath || !expectedNonce || !this._isOwnedHandoffPath(handoffPath)) { + return; + } + + try { + const { header } = await readBootstrapHandoffFileAsync(handoffPath); + if (header?.nonce !== expectedNonce) { + return; + } + } catch { + // Match replay behavior for an unreadable file at an authenticated private path. + } + + await deleteBootstrapHandoffFileAsync(handoffPath); + } + /** * Deletes abandoned handoff files older than the retention window. * diff --git a/libraries/reporter/src/test/ReporterHost.test.ts b/libraries/reporter/src/test/ReporterHost.test.ts index 4ebca154427..f1a53954a5d 100644 --- a/libraries/reporter/src/test/ReporterHost.test.ts +++ b/libraries/reporter/src/test/ReporterHost.test.ts @@ -300,7 +300,45 @@ describe('ReporterHost handoff replay', () => { }); const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); - expect(result).toMatchObject({ replayed: false, skipReason: 'invalid-event' }); + expect(result).toMatchObject({ replayed: false, skipReason: 'unsupported-required-event' }); + }); + }); +}); + +describe('ReporterHost handoff discard', () => { + it('deletes only the current authenticated handoff', async () => { + await withTempDir(async (directory: string) => { + const buffer: BootstrapEventBuffer = makeBuffer(); + buffer.emit({ type: 'sessionStarted', payload: {} }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const host: ReporterHost = new ReporterHost({ + env: { + [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: handoffPath, + [RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]: nonce + }, + handoffDirectory: directory + }); + + await host.discardBootstrapHandoffAsync(); + expect(fs.existsSync(handoffPath)).toBe(false); + }); + }); + + it('does not delete a handoff with a mismatched nonce', async () => { + await withTempDir(async (directory: string) => { + const buffer: BootstrapEventBuffer = makeBuffer(); + buffer.emit({ type: 'sessionStarted', payload: {} }); + const { handoffPath } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const host: ReporterHost = new ReporterHost({ + env: { + [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: handoffPath, + [RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]: 'wrong-nonce' + }, + handoffDirectory: directory + }); + + await host.discardBootstrapHandoffAsync(); + expect(fs.existsSync(handoffPath)).toBe(true); }); }); }); From c1e13cde2c4fd92d0b271a9a3a4d535ddf2ebd65 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 16:37:55 +0000 Subject: [PATCH 3/5] Fix reporter bootstrap review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- apps/rush/src/IRushFrontendLaunchOptions.ts | 1 + apps/rush/src/RushCommandSelector.ts | 107 +++++--- apps/rush/src/RushFrontend.ts | 2 + apps/rush/src/RushReporterHost.ts | 46 +++- .../rush/src/test/RushCommandSelector.test.ts | 233 ++++++++++++++++-- apps/rush/src/test/RushFrontend.test.ts | 4 + apps/rush/src/test/RushReporterHost.test.ts | 130 +++++++++- common/reviews/api/rush-reporter.api.md | 2 +- .../src/compat/OldEngineOutputAdapter.ts | 4 +- .../reporter/src/frontend/ReporterHost.ts | 3 +- .../reporter/src/manager/ReporterManager.ts | 28 ++- .../reporter/src/test/Compatibility.test.ts | 18 +- libraries/reporter/src/test/Manager.test.ts | 25 +- .../reporter/src/test/ReporterHost.test.ts | 30 +++ .../src/scripts/InstallRunRushBootstrap.ts | 46 ++-- .../rush-lib/src/scripts/install-run-rush.ts | 12 +- libraries/rush-lib/src/scripts/install-run.ts | 58 +++-- .../test/InstallRunRushBootstrap.test.ts | 73 +++++- .../scripts/test/InstallRunScripts.test.ts | 153 ++++++++++++ 19 files changed, 859 insertions(+), 116 deletions(-) create mode 100644 libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts diff --git a/apps/rush/src/IRushFrontendLaunchOptions.ts b/apps/rush/src/IRushFrontendLaunchOptions.ts index 99c82748768..bcd921de156 100644 --- a/apps/rush/src/IRushFrontendLaunchOptions.ts +++ b/apps/rush/src/IRushFrontendLaunchOptions.ts @@ -16,6 +16,7 @@ export interface IRushFrontendLaunchOptions extends ILaunchOptions { readonly reporterEventSink: IReporterEventSink; readonly reporterCloseAsync: () => Promise; readonly reporterEnabled: boolean; + readonly reporterStdoutIsMachineReadable?: boolean; readonly reporterSelectionReason: | 'explicit --reporter' | 'repository experiment' diff --git a/apps/rush/src/RushCommandSelector.ts b/apps/rush/src/RushCommandSelector.ts index f486016b4da..13c92a0df52 100644 --- a/apps/rush/src/RushCommandSelector.ts +++ b/apps/rush/src/RushCommandSelector.ts @@ -57,6 +57,7 @@ export class RushCommandSelector { } ); let effectiveOptions: IRushFrontendLaunchOptions = options; + let restoreOldEngineOutput: (() => void) | undefined; if (compatibility.mode !== 'structured' && engineProtocolMajor !== undefined && options.reporterEnabled) { if (options.reporterSelectionReason === 'explicit --reporter') { throw new Error( @@ -72,60 +73,92 @@ export class RushCommandSelector { reporterSelectionReason: 'bootstrap compatibility fallback' }; } else if (compatibility.mode === 'new-frontend-old-engine' && options.reporterEnabled) { - _observeOldEngineOutput(options, Rush.version); + restoreOldEngineOutput = _observeOldEngineOutput(options, Rush.version); } - if (commandName === 'rush-pnpm') { - if (!Rush.launchRushPnpm) { - _failWithError( - `This repository is using Rush version ${Rush.version}` + - ` which does not support the "rush-pnpm" command` - ); - } - Rush.launchRushPnpm(launcherVersion, { - isManaged: options.isManaged, - alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError - }); - } else if (commandName === 'rushx') { - if (!Rush.launchRushX) { - _failWithError( - `This repository is using Rush version ${Rush.version}` + - ` which does not support the "rushx" command` - ); + try { + if (commandName === 'rush-pnpm') { + if (!Rush.launchRushPnpm) { + _failWithError( + `This repository is using Rush version ${Rush.version}` + + ` which does not support the "rush-pnpm" command` + ); + } + Rush.launchRushPnpm(launcherVersion, { + isManaged: options.isManaged, + alreadyReportedNodeTooNewError: options.alreadyReportedNodeTooNewError + }); + } else if (commandName === 'rushx') { + if (!Rush.launchRushX) { + _failWithError( + `This repository is using Rush version ${Rush.version}` + + ` which does not support the "rushx" command` + ); + } + Rush.launchRushX(launcherVersion, effectiveOptions); + } else { + Rush.launch(launcherVersion, effectiveOptions); } - Rush.launchRushX(launcherVersion, effectiveOptions); - } else { - Rush.launch(launcherVersion, effectiveOptions); + } catch (error) { + restoreOldEngineOutput?.(); + throw error; } } } -function _observeOldEngineOutput(options: IRushFrontendLaunchOptions, engineVersion: string): void { +function _observeOldEngineOutput(options: IRushFrontendLaunchOptions, engineVersion: string): () => void { const adapter: OldEngineOutputAdapter = new OldEngineOutputAdapter({ sink: options.reporterEventSink, sessionId: `rush_old_engine_${process.pid}`, source: { packageName: '@microsoft/rush-lib', packageVersion: engineVersion } }); - const legacyWrite: typeof process.stderr.write = process.stderr.write.bind(process.stderr); - _observeStream(process.stdout, 'stdout', adapter, legacyWrite); - _observeStream(process.stderr, 'stderr', adapter, legacyWrite); + const restoreStdout: () => void = _observeStream( + process.stdout, + 'stdout', + adapter, + process.stdout.write.bind(process.stdout), + options.reporterStdoutIsMachineReadable !== true + ); + const restoreStderr: () => void = _observeStream( + process.stderr, + 'stderr', + adapter, + process.stderr.write.bind(process.stderr), + true + ); + let restored: boolean = false; + const restore: () => void = () => { + if (restored) { + return; + } + restored = true; + process.removeListener('beforeExit', restore); + process.removeListener('exit', restore); + restoreStdout(); + restoreStderr(); + }; + process.once('beforeExit', restore); + process.once('exit', restore); + return restore; } function _observeStream( stream: NodeJS.WriteStream, streamName: 'stdout' | 'stderr', adapter: OldEngineOutputAdapter, - legacyWrite: typeof process.stderr.write -): void { + legacyWrite: typeof process.stdout.write, + renderLive: boolean +): () => void { const marker: symbol = Symbol.for(`rush.reporter.old-engine-output.${streamName}`); const markedStream: NodeJS.WriteStream & { [key: symbol]: boolean | undefined } = stream as NodeJS.WriteStream & { [key: symbol]: boolean | undefined }; if (markedStream[marker]) { - return; + return () => {}; } markedStream[marker] = true; const decoder: StringDecoder = new StringDecoder('utf8'); + const originalWrite: typeof stream.write = stream.write; stream.write = (( chunk: string | Uint8Array, encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), @@ -136,13 +169,29 @@ function _observeStream( ? chunk : decoder.write(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)); if (text) { - adapter.capture(streamName, text); + adapter.capture(streamName, text, renderLive); + } + if (!renderLive) { + const writeCallback: ((error?: Error | null) => void) | undefined = + typeof encodingOrCallback === 'function' ? encodingOrCallback : callback; + if (writeCallback) { + process.nextTick(writeCallback); + } + return true; } if (typeof encodingOrCallback === 'function') { return legacyWrite(chunk, encodingOrCallback); } return legacyWrite(chunk, encodingOrCallback, callback); }) as typeof stream.write; + return () => { + const remaining: string = decoder.end(); + if (remaining) { + adapter.capture(streamName, remaining, renderLive); + } + stream.write = originalWrite; + delete markedStream[marker]; + }; } function _failWithError(message: string): never { diff --git a/apps/rush/src/RushFrontend.ts b/apps/rush/src/RushFrontend.ts index da4f535702b..0f797960d73 100644 --- a/apps/rush/src/RushFrontend.ts +++ b/apps/rush/src/RushFrontend.ts @@ -157,6 +157,8 @@ export async function launchRushFrontendAsync(options: IRushFrontendOptions): Pr reporterEventSink: reporterHost.sink, reporterCloseAsync, reporterEnabled: reporterHost.selection.enabled, + reporterStdoutIsMachineReadable: + reporterHost.selection.reporter === 'ai' || reporterHost.selection.reporter === 'json', reporterSelectionReason: reporterHost.selection.reason }; diff --git a/apps/rush/src/RushReporterHost.ts b/apps/rush/src/RushReporterHost.ts index 2944e364fb7..64f1554e8a1 100644 --- a/apps/rush/src/RushReporterHost.ts +++ b/apps/rush/src/RushReporterHost.ts @@ -122,6 +122,40 @@ class LogLevelReporter implements IReporter { } } +class VisibleBootstrapOutputFilterReporter implements IReporter { + public readonly name: string; + + private readonly _reporter: IReporter; + + public constructor(reporter: IReporter) { + this._reporter = reporter; + this.name = reporter.name; + } + + public initializeAsync(context: IReporterContext): Promise { + return this._reporter.initializeAsync(context); + } + + public report(event: IReporterEventEnvelope): void { + const payload: { readonly wasRendered?: unknown } | undefined = + typeof event.payload === 'object' && event.payload !== null + ? (event.payload as { readonly wasRendered?: unknown }) + : undefined; + if (event.type === 'externalOutput' && payload?.wasRendered === true) { + return; + } + this._reporter.report(event); + } + + public flushAsync(): Promise { + return this._reporter.flushAsync(); + } + + public closeAsync(): Promise { + return this._reporter.closeAsync(); + } +} + class ExplicitOutputReporter implements IReporter { public readonly name: string; @@ -639,9 +673,15 @@ export async function initializeRushReporterHostAsync( if (selection.enabled) { const primaryReporter: IReporter | undefined = createPrimaryReporter(selection, stdout, env); if (primaryReporter) { - host.manager.addReporter(new LogLevelReporter(primaryReporter, selection.logLevel), { - destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' - }); + const filteredReporter: IReporter = new LogLevelReporter(primaryReporter, selection.logLevel); + host.manager.addReporter( + selection.reporter === 'default' || selection.reporter === 'plaintext' + ? new VisibleBootstrapOutputFilterReporter(filteredReporter) + : filteredReporter, + { + destination: selection.reporter === 'file' ? 'file:auto' : 'stdout' + } + ); } const hasExplicitFileOutput: boolean = selection.outputs.some( diff --git a/apps/rush/src/test/RushCommandSelector.test.ts b/apps/rush/src/test/RushCommandSelector.test.ts index 1db9f261068..aaecd41ced8 100644 --- a/apps/rush/src/test/RushCommandSelector.test.ts +++ b/apps/rush/src/test/RushCommandSelector.test.ts @@ -26,8 +26,29 @@ class RecordingReporter implements IReporter { public async closeAsync(): Promise {} } +type BeforeExitListener = (code: number) => void; + +function restoreObservedOutput( + previousBeforeExitListeners: readonly BeforeExitListener[], + required: boolean = true +): void { + const currentListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + const restoreListener: BeforeExitListener | undefined = currentListeners.find( + (listener: BeforeExitListener) => !previousBeforeExitListeners.includes(listener) + ); + if (!restoreListener) { + if (required) { + throw new Error('Expected an old-engine output restoration listener.'); + } + return; + } + restoreListener(0); +} + describe(RushCommandSelector.name, () => { - it('keeps old-engine legacy output visible while bridging it to the frontend host', async () => { + it('keeps ordered old-engine stdout and stderr on their original streams', async () => { const manager: ReporterManager = new ReporterManager(); const reporter: RecordingReporter = new RecordingReporter(); manager.addReporter(reporter); @@ -36,19 +57,27 @@ describe(RushCommandSelector.name, () => { const originalArgv: string[] = process.argv; const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; const originalStderrWrite: typeof process.stderr.write = process.stderr.write; - const marker: symbol = Symbol.for('rush.reporter.old-engine-output.stdout'); - const markedStdout: NodeJS.WriteStream & { [key: symbol]: boolean | undefined } = - process.stdout as unknown as NodeJS.WriteStream & { [key: symbol]: boolean | undefined }; - let visibleOutput: string = ''; + let stdoutText: string = ''; + let stderrText: string = ''; process.argv = ['node', 'rush', 'build']; - process.stderr.write = ((text: string): boolean => { - visibleOutput += text; + const stdoutWrite: typeof process.stdout.write = ((text: string): boolean => { + stdoutText += text; + return true; + }) as typeof process.stdout.write; + const stderrWrite: typeof process.stderr.write = ((text: string): boolean => { + stderrText += text; return true; }) as typeof process.stderr.write; + process.stdout.write = stdoutWrite; + process.stderr.write = stderrWrite; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; const options: IRushFrontendLaunchOptions = { isManaged: true, reporterEventSink: manager, + reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'explicit --reporter' }; @@ -56,29 +85,147 @@ describe(RushCommandSelector.name, () => { Rush: { version: '5.177.0', launch: () => { - process.stdout.write('legacy engine output\n'); + process.stdout.write('stdout 1\n'); + process.stderr.write('stderr 1\n'); + process.stdout.write('stdout 2\n'); } } } as unknown as typeof import('@microsoft/rush-lib'); try { RushCommandSelector.execute('5.178.1', oldRushLib, options); + expect(process.stdout.write).not.toBe(stdoutWrite); + expect(process.stderr.write).not.toBe(stderrWrite); + restoreObservedOutput(previousBeforeExitListeners); await manager.flushAsync(); + expect(process.stdout.write).toBe(stdoutWrite); + expect(process.stderr.write).toBe(stderrWrite); } finally { + restoreObservedOutput(previousBeforeExitListeners, false); process.stdout.write = originalStdoutWrite; process.stderr.write = originalStderrWrite; - delete markedStdout[marker]; - delete (process.stderr as unknown as { [key: symbol]: boolean | undefined })[ - Symbol.for('rush.reporter.old-engine-output.stderr') - ]; process.argv = originalArgv; } - expect(visibleOutput).toBe('legacy engine output\n'); - expect(reporter.events).toHaveLength(1); - expect(reporter.events[0]).toMatchObject({ - type: 'externalOutput', - payload: { stream: 'stdout', text: 'legacy engine output\n' } + expect(stdoutText).toBe('stdout 1\nstdout 2\n'); + expect(stderrText).toBe('stderr 1\n'); + expect(reporter.events.map((event) => event.payload)).toEqual([ + { stream: 'stdout', text: 'stdout 1\n', wasRendered: true }, + { stream: 'stderr', text: 'stderr 1\n', wasRendered: true }, + { stream: 'stdout', text: 'stdout 2\n', wasRendered: true } + ]); + }); + + it('captures asynchronous old-engine output until the process lifecycle completes', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + const stdoutWrite: typeof process.stdout.write = (() => true) as typeof process.stdout.write; + const stderrWrite: typeof process.stderr.write = (() => true) as typeof process.stderr.write; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = stdoutWrite; + process.stderr.write = stderrWrite; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + + try { + RushCommandSelector.execute( + '5.178.1', + { + Rush: { + version: '5.177.0', + launch: () => { + setImmediate(() => { + process.stdout.write('async stdout\n'); + process.stderr.write('async stderr\n'); + }); + } + } + } as unknown as typeof import('@microsoft/rush-lib'), + { + isManaged: true, + reporterEventSink: manager, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + } + ); + await new Promise((resolve) => setImmediate(resolve)); + restoreObservedOutput(previousBeforeExitListeners); + await manager.flushAsync(); + } finally { + restoreObservedOutput(previousBeforeExitListeners, false); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.argv = originalArgv; + } + + expect(reporter.events.map((event) => event.payload)).toEqual([ + { stream: 'stdout', text: 'async stdout\n', wasRendered: true }, + { stream: 'stderr', text: 'async stderr\n', wasRendered: true } + ]); + }); + + it('keeps old-engine stdout structured for machine reporters', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter(); + manager.addReporter(reporter); + await manager.initializeAsync(); + + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + let stdoutText: string = ''; + const stdoutWrite: typeof process.stdout.write = ((text: string): boolean => { + stdoutText += text; + return true; + }) as typeof process.stdout.write; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = stdoutWrite; + process.stderr.write = (() => true) as typeof process.stderr.write; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; + + try { + RushCommandSelector.execute( + '5.178.1', + { + Rush: { + version: '5.177.0', + launch: () => { + process.stdout.write('legacy stdout\n'); + } + } + } as unknown as typeof import('@microsoft/rush-lib'), + { + isManaged: true, + reporterEventSink: manager, + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterStdoutIsMachineReadable: true, + reporterSelectionReason: 'explicit --reporter' + } + ); + restoreObservedOutput(previousBeforeExitListeners); + await manager.flushAsync(); + } finally { + restoreObservedOutput(previousBeforeExitListeners, false); + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.argv = originalArgv; + } + + expect(stdoutText).toBe(''); + expect(reporter.events[0].payload).toEqual({ + stream: 'stdout', + text: 'legacy stdout\n' }); }); @@ -97,6 +244,9 @@ describe(RushCommandSelector.name, () => { process.argv = ['node', 'rush', 'build']; process.stdout.write = (() => true) as typeof process.stdout.write; process.stderr.write = (() => true) as typeof process.stderr.write; + const previousBeforeExitListeners: readonly BeforeExitListener[] = process.listeners( + 'beforeExit' + ) as BeforeExitListener[]; const oldRushLib = { Rush: { @@ -112,11 +262,14 @@ describe(RushCommandSelector.name, () => { RushCommandSelector.execute('5.178.1', oldRushLib, { isManaged: true, reporterEventSink: manager, + reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'explicit --reporter' }); + restoreObservedOutput(previousBeforeExitListeners); await manager.flushAsync(); } finally { + restoreObservedOutput(previousBeforeExitListeners, false); process.stdout.write = originalStdoutWrite; process.stderr.write = originalStderrWrite; delete markedStdout[marker]; @@ -127,13 +280,54 @@ describe(RushCommandSelector.name, () => { } expect(reporter.events).toHaveLength(1); - expect(reporter.events[0].payload).toEqual({ stream: 'stdout', text: '€' }); + expect(reporter.events[0].payload).toEqual({ stream: 'stdout', text: '€', wasRendered: true }); + }); + + it('restores old-engine stream writers when launch throws', () => { + const originalArgv: string[] = process.argv; + const originalStdoutWrite: typeof process.stdout.write = process.stdout.write; + const originalStderrWrite: typeof process.stderr.write = process.stderr.write; + const stdoutWrite: typeof process.stdout.write = (() => true) as typeof process.stdout.write; + const stderrWrite: typeof process.stderr.write = (() => true) as typeof process.stderr.write; + process.argv = ['node', 'rush', 'build']; + process.stdout.write = stdoutWrite; + process.stderr.write = stderrWrite; + + try { + expect(() => + RushCommandSelector.execute( + '5.178.1', + { + Rush: { + version: '5.177.0', + launch: () => { + throw new Error('launch failed'); + } + } + } as unknown as typeof import('@microsoft/rush-lib'), + { + isManaged: true, + reporterEventSink: new ReporterManager(), + reporterCloseAsync: async () => {}, + reporterEnabled: true, + reporterSelectionReason: 'explicit --reporter' + } + ) + ).toThrow('launch failed'); + expect(process.stdout.write).toBe(stdoutWrite); + expect(process.stderr.write).toBe(stderrWrite); + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + process.argv = originalArgv; + } }); it('fails an explicit reporter request for an incompatible new engine protocol', () => { const options: IRushFrontendLaunchOptions = { isManaged: true, reporterEventSink: new ReporterManager(), + reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'explicit --reporter' }; @@ -154,6 +348,7 @@ describe(RushCommandSelector.name, () => { const options: IRushFrontendLaunchOptions = { isManaged: true, reporterEventSink: new ReporterManager(), + reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'explicit --reporter' }; @@ -175,6 +370,7 @@ describe(RushCommandSelector.name, () => { const options: IRushFrontendLaunchOptions = { isManaged: true, reporterEventSink: new ReporterManager(), + reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'repository experiment' }; @@ -202,6 +398,7 @@ describe(RushCommandSelector.name, () => { const options: IRushFrontendLaunchOptions = { isManaged: true, reporterEventSink: new ReporterManager(), + reporterCloseAsync: async () => {}, reporterEnabled: true, reporterSelectionReason: 'repository experiment' }; diff --git a/apps/rush/src/test/RushFrontend.test.ts b/apps/rush/src/test/RushFrontend.test.ts index 3e4135372fb..065081d91f6 100644 --- a/apps/rush/src/test/RushFrontend.test.ts +++ b/apps/rush/src/test/RushFrontend.test.ts @@ -68,6 +68,8 @@ async function createEnabledHostAsync( return { host, sink: host.getSink(), + bootstrapReplay: { direct: true, replayed: false, eventCount: 0 }, + abandonedHandoffFilesDeleted: [], selection: { reporter: 'json', logLevel: 'normal', @@ -105,6 +107,8 @@ async function createPhaseHangingHostAsync( return { host, sink: host.getSink(), + bootstrapReplay: { direct: true, replayed: false, eventCount: 0 }, + abandonedHandoffFilesDeleted: [], selection: { reporter: 'json', logLevel: 'normal', diff --git a/apps/rush/src/test/RushReporterHost.test.ts b/apps/rush/src/test/RushReporterHost.test.ts index fe9d32531cc..b90aac496dd 100644 --- a/apps/rush/src/test/RushReporterHost.test.ts +++ b/apps/rush/src/test/RushReporterHost.test.ts @@ -74,11 +74,9 @@ describe(resolveRushReporterSelection.name, () => { enabled: true, reason: 'explicit --reporter' }); - expect(resolve(['build'], { RUSH_REPORTER: 'json' })).toMatchObject({ - reporter: 'legacy', - enabled: false, - reason: 'pre-major legacy default' - }); + expect(() => resolve(['build'], { RUSH_REPORTER: 'json' })).toThrow( + /cannot enable the pre-major reporter path/ + ); }); it('uses deterministic non-agent selection for the repository experiment', () => { @@ -282,6 +280,9 @@ describe(resolveRushReporterSelection.name, () => { enabled: false, reason: 'pre-major legacy default' }); + expect( + resolve(['build', '--reporter=json', '--', '--reporter=unknown', '--log-level=invalid']) + ).toMatchObject({ reporter: 'json', enabled: true }); }); it('applies CLI log-level controls before RUSH_LOG_LEVEL and rejects contradictions', () => { @@ -505,6 +506,125 @@ describe(initializeRushReporterHostAsync.name, () => { } }); + it('does not replay live bootstrap output to the same visible destination', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + const outputPath: string = path.join(directory, 'events.jsonl'); + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream: 'stdout', text: 'npm output\n', wasRendered: true } + }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: [ + 'build', + '--reporter=plaintext', + '--log-level=debug', + `--output=json://${outputPath}?logLevel=debug` + ], + env, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + initialized.sink.emit({ + protocolVersion: { major: 1, minor: 0 }, + sessionId: 'old-engine-session', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.177.0' }, + privacy: 'local-sensitive', + type: 'externalOutput', + payload: { stream: 'stderr', text: 'old engine output\n', wasRendered: true } + }); + await initialized.host.manager.closeAsync(); + + expect(stdoutText).toBe(''); + expect( + (await fs.promises.readFile(outputPath, 'utf8')) + .trim() + .split('\n') + .map((line: string) => JSON.parse(line)) + ).toEqual([ + expect.objectContaining({ + type: 'externalOutput', + payload: { stream: 'stdout', text: 'npm output\n', wasRendered: true } + }), + expect.objectContaining({ + type: 'externalOutput', + payload: { stream: 'stderr', text: 'old engine output\n', wasRendered: true } + }) + ]); + expect(fs.existsSync(handoffPath)).toBe(false); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + + it('retains bootstrap stdout and stderr records in the primary JSON stream', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); + const env: Record = {}; + let stdoutText: string = ''; + try { + const buffer: BootstrapEventBuffer = new BootstrapEventBuffer({ + sessionId: 'bootstrap-session', + source: { packageName: 'install-run-rush', packageVersion: '5.178.1' } + }); + buffer.emit({ + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream: 'stdout', text: 'captured stdout\n' } + }); + buffer.emit({ + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream: 'stderr', text: 'live stderr\n', wasRendered: true } + }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR] = handoffPath; + env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR] = nonce; + + const initialized = await initializeRushReporterHostAsync({ + argv: ['build', '--reporter=json', '--log-level=debug'], + env, + handoffDirectory: directory, + stdout: { + isTTY: false, + write: (text: string) => { + stdoutText += text; + } + }, + includeDefaultFileReporter: false + }); + await initialized.closeAsync(); + + expect( + stdoutText + .trim() + .split('\n') + .map((line: string) => JSON.parse(line).payload) + ).toEqual([ + { stream: 'stdout', text: 'captured stdout\n' }, + { stream: 'stderr', text: 'live stderr\n', wasRendered: true } + ]); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + it('restores ordered legacy output when repository opt-in meets an incompatible handoff', async () => { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rush-frontend-')); const env: Record = {}; diff --git a/common/reviews/api/rush-reporter.api.md b/common/reviews/api/rush-reporter.api.md index 3865d31aefc..14a1ec55f14 100644 --- a/common/reviews/api/rush-reporter.api.md +++ b/common/reviews/api/rush-reporter.api.md @@ -1246,7 +1246,7 @@ export function normalizeAnsi(text: string): string; // @beta export class OldEngineOutputAdapter { constructor(options: IOldEngineOutputAdapterOptions); - capture(stream: 'stdout' | 'stderr', text: string): string[]; + capture(stream: 'stdout' | 'stderr', text: string, wasRendered?: boolean): string[]; } // @beta diff --git a/libraries/reporter/src/compat/OldEngineOutputAdapter.ts b/libraries/reporter/src/compat/OldEngineOutputAdapter.ts index 06d6e3ecee1..fddfa856d51 100644 --- a/libraries/reporter/src/compat/OldEngineOutputAdapter.ts +++ b/libraries/reporter/src/compat/OldEngineOutputAdapter.ts @@ -74,7 +74,7 @@ export class OldEngineOutputAdapter { * @param stream - the originating stream * @param text - the raw output text */ - public capture(stream: 'stdout' | 'stderr', text: string): string[] { + public capture(stream: 'stdout' | 'stderr', text: string, wasRendered: boolean = true): string[] { const eventIds: string[] = []; for (const chunk of chunkUtf8Text(text, this._maxChunkBytes)) { eventIds.push( @@ -84,7 +84,7 @@ export class OldEngineOutputAdapter { source: this._source, privacy: 'local-sensitive', type: 'externalOutput', - payload: { stream, text: chunk } + payload: { stream, text: chunk, ...(wasRendered ? { wasRendered: true } : {}) } }) ); } diff --git a/libraries/reporter/src/frontend/ReporterHost.ts b/libraries/reporter/src/frontend/ReporterHost.ts index 7f1c4fa19c5..1c8c85faa7f 100644 --- a/libraries/reporter/src/frontend/ReporterHost.ts +++ b/libraries/reporter/src/frontend/ReporterHost.ts @@ -178,7 +178,8 @@ function getLegacyFallbackOutput(events: readonly unknown[]): IBootstrapLegacyOu if ( event.type === 'externalOutput' && (event.payload.stream === 'stdout' || event.payload.stream === 'stderr') && - typeof event.payload.text === 'string' + typeof event.payload.text === 'string' && + event.payload.wasRendered !== true ) { output.push({ stream: event.payload.stream, text: event.payload.text }); } else if (event.type === 'activityChanged' && typeof event.payload.text === 'string') { diff --git a/libraries/reporter/src/manager/ReporterManager.ts b/libraries/reporter/src/manager/ReporterManager.ts index 657e88c17a6..b9aba350aef 100644 --- a/libraries/reporter/src/manager/ReporterManager.ts +++ b/libraries/reporter/src/manager/ReporterManager.ts @@ -352,10 +352,14 @@ export class ReporterManager implements IReporterEventSink { entry.queue.push(envelope); } - if (!entry.draining) { - entry.draining = true; - entry.drainPromise = this._drainEntryAsync(entry); + if (entry.draining) { + if (!this._isCoalescibleStatusEvent(envelope)) { + this._drainQueuedEventsSynchronously(entry); + } + return; } + entry.draining = true; + entry.drainPromise = this._drainEntryAsync(entry); } private async _drainEntryAsync(entry: IReporterEntry): Promise { @@ -367,8 +371,11 @@ export class ReporterManager implements IReporterEventSink { entry.queue.length = 0; break; } - // Yield so producers and coalescing can interleave with delivery. - await Promise.resolve(); + // Only replaceable status updates need to yield for coalescing. Protected + // events are delivered synchronously so a hard process exit cannot strand them. + if (this._isCoalescibleStatusEvent(envelope)) { + await Promise.resolve(); + } } } finally { entry.draining = false; @@ -383,6 +390,17 @@ export class ReporterManager implements IReporterEventSink { } } + private _drainQueuedEventsSynchronously(entry: IReporterEntry): void { + while (entry.queue.length > 0) { + const envelope: IReporterEventEnvelope = entry.queue.shift()!; + this._deliverEnvelope(entry, envelope); + if (entry.disabled) { + entry.queue.length = 0; + break; + } + } + } + private _handleReporterFailure(entry: IReporterEntry, error: Error): void { if (entry.required) { if (!this._fatalError) { diff --git a/libraries/reporter/src/test/Compatibility.test.ts b/libraries/reporter/src/test/Compatibility.test.ts index 971e8dcffc2..cdb14abdb37 100644 --- a/libraries/reporter/src/test/Compatibility.test.ts +++ b/libraries/reporter/src/test/Compatibility.test.ts @@ -139,7 +139,8 @@ describe('OldEngineOutputAdapter', () => { expect(event.privacy).toBe('local-sensitive'); expect(event.payload).toEqual({ stream: 'stdout', - text: 'Building project-a...\nproject-a done.\n' + text: 'Building project-a...\nproject-a done.\n', + wasRendered: true }); }); @@ -175,14 +176,13 @@ describe('OldEngineOutputAdapter', () => { }); it('rejects a chunk limit smaller than one UTF-8 code point', () => { - expect( - () => - new OldEngineOutputAdapter({ - sink: new ReporterManager(), - sessionId: 'sess', - source: { packageName: '@microsoft/rush-lib', packageVersion: '5.60.0' }, - maxChunkBytes: 1 - }).capture('stdout', '😀') + expect(() => + new OldEngineOutputAdapter({ + sink: new ReporterManager(), + sessionId: 'sess', + source: { packageName: '@microsoft/rush-lib', packageVersion: '5.60.0' }, + maxChunkBytes: 1 + }).capture('stdout', '😀') ).toThrow(/at least 4/); }); }); diff --git a/libraries/reporter/src/test/Manager.test.ts b/libraries/reporter/src/test/Manager.test.ts index 582f88a8c23..9da2ac3034f 100644 --- a/libraries/reporter/src/test/Manager.test.ts +++ b/libraries/reporter/src/test/Manager.test.ts @@ -108,6 +108,24 @@ describe('ReporterManager ordering and assignment', () => { expect(reporter.reported[0].timestamp).toBe('2026-01-01T00:00:00.000Z'); }); + it('delivers protected events synchronously so hard exits cannot strand output', async () => { + const manager: ReporterManager = new ReporterManager(); + const reporter: RecordingReporter = new RecordingReporter('a'); + manager.addReporter(reporter); + await manager.initializeAsync(); + + manager.emit(makeInput('activityChanged', { text: 'status' })); + manager.emit(makeInput('externalOutput', { text: 'first' })); + manager.emit(makeInput('externalOutput', { text: 'second' })); + + expect(reporter.reported.map((event: IReporterEventEnvelope) => event.payload)).toEqual([ + { text: 'status' }, + { text: 'first' }, + { text: 'second' } + ]); + expect(manager.getPendingEventCount()).toBe(0); + }); + it('derives the required flag from the event type, ignoring producer input', async () => { const manager: ReporterManager = new ReporterManager(); const reporter: RecordingReporter = new RecordingReporter('a'); @@ -152,9 +170,10 @@ describe('ReporterManager ordering and assignment', () => { manager.ingestForeignEnvelope(foreign); await manager.flushAsync(); - const byIdentity: [string, string][] = reporter.reported.map( - (e: IReporterEventEnvelope) => [e.sessionId, e.eventId] - ); + const byIdentity: [string, string][] = reporter.reported.map((e: IReporterEventEnvelope) => [ + e.sessionId, + e.eventId + ]); expect(byIdentity).toEqual([ ['sess', 'evt_1'], ['child', 'evt_1'] diff --git a/libraries/reporter/src/test/ReporterHost.test.ts b/libraries/reporter/src/test/ReporterHost.test.ts index f1a53954a5d..ec2d8379ea3 100644 --- a/libraries/reporter/src/test/ReporterHost.test.ts +++ b/libraries/reporter/src/test/ReporterHost.test.ts @@ -209,6 +209,36 @@ describe('ReporterHost handoff replay', () => { }); }); + it('does not duplicate already-rendered output during legacy fallback', async () => { + await withTempDir(async (directory: string) => { + const buffer: BootstrapEventBuffer = makeBuffer(); + buffer.emit({ + type: 'externalOutput', + privacy: 'local-sensitive', + payload: { stream: 'stdout', text: 'live output\n', wasRendered: true } + }); + const { handoffPath, nonce } = await writeBootstrapHandoffFileAsync(buffer, { directory }); + const contents: string = await fs.promises.readFile(handoffPath, 'utf8'); + await fs.promises.writeFile(handoffPath, contents.replace('"major":1', '"major":2')); + + const manager: ReporterManager = new ReporterManager(); + await manager.initializeAsync(); + const host: ReporterHost = new ReporterHost({ + manager, + env: { + [RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]: handoffPath, + [RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]: nonce + }, + handoffDirectory: directory + }); + const result: IBootstrapReplayResult = await host.replayBootstrapHandoffAsync(); + + expect(result.skipReason).toBe('incompatible-protocol'); + expect(result.legacyFallbackOutput).toBeUndefined(); + expect(fs.existsSync(handoffPath)).toBe(false); + }); + }); + it('replays a valid prefix before a malformed trailing record', async () => { await withTempDir(async (directory: string) => { const buffer: BootstrapEventBuffer = makeBuffer(); diff --git a/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts index fc86bc729e1..533d0f024e1 100644 --- a/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts +++ b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts @@ -72,7 +72,10 @@ export interface IInstallRunRushBootstrap { readonly enabled: boolean; readonly logger: ILogger; readonly externalOutputCaptureMaxBytes: number | undefined; - readonly externalOutputHandler: ((stream: BootstrapStream, text: string) => void) | undefined; + readonly externalOutputHandler: + | ((stream: BootstrapStream, text: string, wasRendered: boolean) => void) + | undefined; + readonly externalOutputLiveStreams: Readonly<{ stdout: boolean; stderr: boolean }> | undefined; readonly externalOutputOverflowHandler: (() => void) | undefined; readonly prepareToRun: (() => void) | undefined; } @@ -82,6 +85,9 @@ function readSingleFlagValue(argv: readonly string[], flag: string): string | un const prefix: string = `${flag}=`; for (let index: number = 0; index < argv.length; index++) { const argument: string = argv[index]; + if (argument === '--') { + break; + } let value: string | undefined; if (argument.startsWith(prefix)) { value = argument.slice(prefix.length); @@ -273,7 +279,12 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { public readonly enabled: boolean = true; public readonly logger: ILogger; public readonly externalOutputCaptureMaxBytes: number; - public readonly externalOutputHandler: (stream: BootstrapStream, text: string) => void; + public readonly externalOutputHandler: ( + stream: BootstrapStream, + text: string, + wasRendered: boolean + ) => void; + public readonly externalOutputLiveStreams: Readonly<{ stdout: boolean; stderr: boolean }>; public readonly externalOutputOverflowHandler: () => void; public readonly prepareToRun: () => void; @@ -288,6 +299,7 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { private readonly _sessionId: string; private readonly _sourceVersion: string; private readonly _entryLimit: number; + private readonly _fallbackStdoutStream: BootstrapStream; private _usedBytes: number; private _nextSequence: number; private _nextEventNumber: number; @@ -295,7 +307,7 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { private _droppedRequired: number; private _failureFlushed: boolean; - public constructor(options: IInstallRunRushBootstrapOptions) { + public constructor(options: IInstallRunRushBootstrapOptions, liveStdout: boolean) { this._entries = []; this._env = options.env; this._stdout = options.stdout ?? ((text: string) => process.stdout.write(text)); @@ -307,6 +319,7 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { this._sessionId = `rush_bootstrap_${process.pid}_${this._randomUUID()}`; this._sourceVersion = options.bootstrapVersion; this._entryLimit = this._maxBytes - TRUNCATION_NOTICE_RESERVE_BYTES; + this._fallbackStdoutStream = liveStdout ? 'stdout' : 'stderr'; if (this._entryLimit <= 0) { throw new RangeError(`maxBytes must be greater than ${TRUNCATION_NOTICE_RESERVE_BYTES}.`); } @@ -317,15 +330,11 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { this._droppedRequired = 0; this._failureFlushed = false; this.externalOutputCaptureMaxBytes = this._maxBytes; + this.externalOutputLiveStreams = { stdout: liveStdout, stderr: true }; this._addEvent({ type: 'sessionStarted', privacy: 'public', - payload: { rushVersion: options.rushVersion, cwd: process.cwd() } - }); - this._addEvent({ - type: 'commandStarted', - privacy: 'public', - payload: { commandName: options.argv[0] ?? 'unknown', argv: options.argv } + payload: { rushVersion: options.rushVersion } }); this.logger = { @@ -336,20 +345,20 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { privacy: 'public', payload: { kind: 'bootstrap', text } }, - { stream: 'stdout', text: `${text}\n` } + { stream: this._fallbackStdoutStream, text: `${text}\n` } ); }, error: (text: string) => { const droppedRequiredBefore: number = this._droppedRequired; - this._addExternalOutput('stderr', `${text}\n`); + this._addExternalOutput('stderr', `${text}\n`, false); this._flushFailureOutput(); if (this._droppedRequired > droppedRequiredBefore) { this._stderr(`${text}\n`); } } }; - this.externalOutputHandler = (stream: BootstrapStream, text: string) => { - this._addExternalOutput(stream, text); + this.externalOutputHandler = (stream: BootstrapStream, text: string, wasRendered: boolean) => { + this._addExternalOutput(stream, text, wasRendered); }; this.externalOutputOverflowHandler = () => { this._droppedRequired++; @@ -406,7 +415,7 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { } } - private _addExternalOutput(stream: BootstrapStream, text: string): void { + private _addExternalOutput(stream: BootstrapStream, text: string, wasRendered: boolean): void { if (!text) { return; } @@ -415,9 +424,11 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { { type: 'externalOutput', privacy: 'local-sensitive', - payload: { stream, text: chunk } + payload: { stream, text: chunk, ...(wasRendered ? { wasRendered: true } : {}) } }, - { stream, text: chunk } + wasRendered + ? undefined + : { stream: stream === 'stdout' ? this._fallbackStdoutStream : stream, text: chunk } ); } } @@ -509,6 +520,7 @@ function createLegacyBootstrap(options: IInstallRunRushBootstrapOptions): IInsta }, externalOutputHandler: undefined, externalOutputCaptureMaxBytes: undefined, + externalOutputLiveStreams: undefined, externalOutputOverflowHandler: undefined, prepareToRun: undefined }; @@ -568,5 +580,5 @@ export function createInstallRunRushBootstrap( return createLegacyBootstrap(options); } - return new InstallRunRushBootstrap(options); + return new InstallRunRushBootstrap(options, explicitReporter !== 'json' && explicitReporter !== 'ai'); } diff --git a/libraries/rush-lib/src/scripts/install-run-rush.ts b/libraries/rush-lib/src/scripts/install-run-rush.ts index d0fd5eb5edb..ff6d7fbf09a 100644 --- a/libraries/rush-lib/src/scripts/install-run-rush.ts +++ b/libraries/rush-lib/src/scripts/install-run-rush.ts @@ -67,8 +67,6 @@ function _getBin(scriptName: string): 'rush' | 'rush-pnpm' | 'rushx' { } function _run(): void { - _validateBundledBootstrapProtocol(); - const [ nodePath /* Ex: /bin/node */, scriptPath /* /repo/common/scripts/install-run-rush.js */, @@ -89,7 +87,9 @@ function _run(): void { let quiet: boolean = quietModeEnvValue === '1' || quietModeEnvValue === 'true'; for (const arg of packageBinArgs) { - if (arg === '-q' || arg === '--quiet') { + if (arg === '--') { + break; + } else if (arg === '-q' || arg === '--quiet') { // The -q/--quiet flag is supported by both `rush` and `rushx`, and will suppress // any normal informational/diagnostic information printed during startup. // @@ -116,11 +116,12 @@ function _run(): void { process.exit(1); } - const rushJsonFolder: string = findRushJsonFolder(); - const rushVersion: { readonly version: string; readonly sourceMessage?: string } = _getRushVersion(); let bootstrap: IInstallRunRushBootstrap | undefined; process.exitCode = 1; try { + _validateBundledBootstrapProtocol(); + const rushJsonFolder: string = findRushJsonFolder(); + const rushVersion: { readonly version: string; readonly sourceMessage?: string } = _getRushVersion(); bootstrap = createInstallRunRushBootstrap({ argv: packageBinArgs, env: process.env, @@ -154,6 +155,7 @@ function _run(): void { onExternalOutput: bootstrap.externalOutputHandler, onExternalOutputOverflow: bootstrap.externalOutputOverflowHandler, externalOutputCaptureMaxBytes: bootstrap.externalOutputCaptureMaxBytes, + externalOutputLiveStreams: bootstrap.externalOutputLiveStreams, prepareToRun: bootstrap.prepareToRun } ); diff --git a/libraries/rush-lib/src/scripts/install-run.ts b/libraries/rush-lib/src/scripts/install-run.ts index 84e350938a0..1b22990f11e 100644 --- a/libraries/rush-lib/src/scripts/install-run.ts +++ b/libraries/rush-lib/src/scripts/install-run.ts @@ -22,11 +22,19 @@ const INSTALLED_FLAG_FILENAME: string = 'installed.flag'; const NODE_MODULES_FOLDER_NAME: string = 'node_modules'; const PACKAGE_JSON_FILENAME: string = 'package.json'; let _externalOutputCaptureId: number = 0; -const NPM_OUTPUT_CAPTURE_SCRIPT: string = ` +export const NPM_OUTPUT_CAPTURE_SCRIPT: string = ` const childProcess = require('node:child_process'); const fs = require('node:fs'); const { StringDecoder } = require('node:string_decoder'); -const [command, argsJson, capturePath, useShell, maxBytesText] = process.argv.slice(1); +const [ + command, + argsJson, + capturePath, + useShell, + maxBytesText, + renderStdoutText, + renderStderrText +] = process.argv.slice(1); const child = childProcess.spawn(command, JSON.parse(argsJson), { cwd: process.cwd(), env: process.env, @@ -38,11 +46,12 @@ const decoders = { stdout: new StringDecoder('utf8'), stderr: new StringDecoder( const maxBytes = Number(maxBytesText); let capturedBytes = 0; let overflowed = false; +const renderedStreams = { stdout: renderStdoutText === '1', stderr: renderStderrText === '1' }; function capture(stream, text) { if (!text || overflowed) { return; } - const record = JSON.stringify({ stream, text }) + '\\n'; + const record = JSON.stringify({ stream, text, wasRendered: renderedStreams[stream] }) + '\\n'; const recordBytes = Buffer.byteLength(record); if (capturedBytes + recordBytes <= maxBytes) { fs.appendFileSync(capturePath, record); @@ -52,8 +61,14 @@ function capture(stream, text) { fs.appendFileSync(capturePath, JSON.stringify({ overflow: true }) + '\\n'); } } -child.stdout.on('data', (chunk) => capture('stdout', decoders.stdout.write(chunk))); -child.stderr.on('data', (chunk) => capture('stderr', decoders.stderr.write(chunk))); +function forwardAndCapture(stream, chunk) { + if (renderedStreams[stream]) { + (stream === 'stdout' ? process.stdout : process.stderr).write(chunk); + } + capture(stream, decoders[stream].write(chunk)); +} +child.stdout.on('data', (chunk) => forwardAndCapture('stdout', chunk)); +child.stderr.on('data', (chunk) => forwardAndCapture('stderr', chunk)); child.on('error', (error) => { process.stderr.write(String(error) + '\\n'); process.exitCode = 1; @@ -71,9 +86,10 @@ child.on('close', (code, signal) => { `; export interface IInstallAndRunOptions { - readonly onExternalOutput?: (stream: 'stdout' | 'stderr', text: string) => void; + readonly onExternalOutput?: (stream: 'stdout' | 'stderr', text: string, wasRendered: boolean) => void; readonly onExternalOutputOverflow?: () => void; readonly externalOutputCaptureMaxBytes?: number; + readonly externalOutputLiveStreams?: Readonly<{ stdout: boolean; stderr: boolean }>; readonly prepareToRun?: () => void; } @@ -409,9 +425,10 @@ function _installPackage( name: string, version: string, npmCommand: 'install' | 'ci', - onExternalOutput: ((stream: 'stdout' | 'stderr', text: string) => void) | undefined, + onExternalOutput: ((stream: 'stdout' | 'stderr', text: string, wasRendered: boolean) => void) | undefined, onExternalOutputOverflow: (() => void) | undefined, - externalOutputCaptureMaxBytes: number | undefined + externalOutputCaptureMaxBytes: number | undefined, + externalOutputLiveStreams: Readonly<{ stdout: boolean; stderr: boolean }> | undefined ): void { let capturePath: string | undefined; try { @@ -433,6 +450,7 @@ function _installPackage( }, capturePath, externalOutputCaptureMaxBytes ?? 1024 * 1024, + externalOutputLiveStreams ?? { stdout: true, stderr: true }, `npm ${npmCommand}` ); } else { @@ -462,7 +480,7 @@ function _installPackage( function _readCapturedNpmOutput( capturePath: string, - onExternalOutput: (stream: 'stdout' | 'stderr', text: string) => void, + onExternalOutput: (stream: 'stdout' | 'stderr', text: string, wasRendered: boolean) => void, onExternalOutputOverflow: (() => void) | undefined ): void { const fileDescriptor: number = fs.openSync(capturePath, 'r'); @@ -481,14 +499,19 @@ function _readCapturedNpmOutput( const line: string = pending.slice(0, newlineIndex); pending = pending.slice(newlineIndex + 1); if (line) { - const record: { stream?: unknown; text?: unknown; overflow?: unknown } = JSON.parse(line); + const record: { + stream?: unknown; + text?: unknown; + wasRendered?: unknown; + overflow?: unknown; + } = JSON.parse(line); if (record.overflow === true) { onExternalOutputOverflow?.(); } else if ( (record.stream === 'stdout' || record.stream === 'stderr') && typeof record.text === 'string' ) { - onExternalOutput(record.stream, record.text); + onExternalOutput(record.stream, record.text, record.wasRendered === true); } } } @@ -562,6 +585,7 @@ function _runNpmWithCaptureConfirmSuccess( options: childProcess.SpawnSyncOptions, capturePath: string, captureMaxBytes: number, + liveStreams: Readonly<{ stdout: boolean; stderr: boolean }>, commandNameForLogging: string ): childProcess.SpawnSyncReturns { const npmPath: string = getNpmPath(); @@ -576,7 +600,9 @@ function _runNpmWithCaptureConfirmSuccess( JSON.stringify(commandArgs), capturePath, IS_WINDOWS ? '1' : '0', - String(captureMaxBytes) + String(captureMaxBytes), + liveStreams.stdout ? '1' : '0', + liveStreams.stderr ? '1' : '0' ], options ); @@ -647,12 +673,16 @@ export function installAndRun( installCommand, options.onExternalOutput, options.onExternalOutputOverflow, - options.externalOutputCaptureMaxBytes + options.externalOutputCaptureMaxBytes, + options.externalOutputLiveStreams ); _writeFlagFile(packageInstallFolder); } - const statusMessage: string = `Invoking "${packageBinName} ${packageBinArgs.join(' ')}"`; + const invocation: string = options.onExternalOutput + ? packageBinName + : `${packageBinName} ${packageBinArgs.join(' ')}`; + const statusMessage: string = `Invoking "${invocation}"`; const statusMessageLine: string = new Array(statusMessage.length + 1).join('-'); logger.info('\n' + statusMessage + '\n' + statusMessageLine + '\n'); options.prepareToRun?.(); diff --git a/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts index 443d63a14c7..b4fa2d9b735 100644 --- a/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts +++ b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts @@ -103,30 +103,79 @@ describe(createInstallRunRushBootstrap.name, () => { const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); bootstrap.logger.info('resolving Rush'); - bootstrap.externalOutputHandler?.('stdout', 'npm line 1\nnpm line 2\n'); + bootstrap.externalOutputHandler?.('stdout', 'npm line 1\nnpm line 2\n', false); bootstrap.logger.info('invoking Rush'); bootstrap.prepareToRun?.(); const handoff = readHandoff(env); expect(bootstrap.enabled).toBe(true); + expect(bootstrap.externalOutputLiveStreams).toEqual({ stdout: false, stderr: true }); expect(stdout).toEqual([]); expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBe( (handoff.records[0] as { nonce?: string }).nonce ); expect(handoff.records.slice(1).map((record: Record) => record.type)).toEqual([ 'sessionStarted', - 'commandStarted', 'activityChanged', 'externalOutput', 'activityChanged' ]); - expect((handoff.records[4].payload as { text: string }).text).toBe('npm line 1\nnpm line 2\n'); + expect((handoff.records[3].payload as { text: string }).text).toBe('npm line 1\nnpm line 2\n'); + expect((handoff.records[3].payload as { wasRendered?: boolean }).wasRendered).toBeUndefined(); if (process.platform !== 'win32') { expect(fs.statSync(handoff.path).mode % 0o1000).toBe(0o600); } }); }); + it('does not publish the working directory or full argv as public bootstrap data', async () => { + await withTempDir(async (directory: string) => { + const secretArgument: string = '--token=bootstrap-secret-value'; + const secretCwd: string = path.join(directory, 'secret-worktree-name'); + const cwdSpy: jest.SpyInstance = jest.spyOn(process, 'cwd').mockReturnValue(secretCwd); + try { + const { options, env } = makeOptions(directory, { + argv: ['build', '--reporter=json', secretArgument] + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.prepareToRun?.(); + + const handoff = readHandoff(env); + const serialized: string = fs.readFileSync(handoff.path, 'utf8'); + expect(serialized).not.toContain(secretArgument); + expect(serialized).not.toContain(secretCwd); + expect(handoff.records[1]).toMatchObject({ + privacy: 'public', + type: 'sessionStarted', + payload: { rushVersion: '5.178.1' } + }); + expect(handoff.records).toHaveLength(2); + } finally { + cwdSpy.mockRestore(); + } + }); + }); + + it('stops parsing reporter controls at the pass-through separator', async () => { + await withTempDir(async (directory: string) => { + expect( + createInstallRunRushBootstrap( + makeOptions(directory, { + argv: ['build', '--', '--reporter=unknown', '--log-level=invalid'] + }).options + ).enabled + ).toBe(false); + + expect( + createInstallRunRushBootstrap( + makeOptions(directory, { + argv: ['build', '--reporter=json', '--', '--reporter=unknown', '--log-level=invalid'] + }).options + ).enabled + ).toBe(true); + }); + }); + it('uses repository opt-in but safely falls back for an old frontend', async () => { await withTempDir(async (directory: string) => { const experimentsFolder: string = path.join(directory, 'common', 'config', 'rush'); @@ -239,13 +288,29 @@ describe(createInstallRunRushBootstrap.name, () => { maxBytes: 800 }); const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); - bootstrap.externalOutputHandler?.('stdout', 'x'.repeat(2000)); + bootstrap.externalOutputHandler?.('stdout', 'x'.repeat(2000), true); expect(() => bootstrap.prepareToRun?.()).toThrow(/could not preserve/); bootstrap.logger.error('bootstrap failed'); expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); expect(stderr.join('')).toContain('bootstrap failed'); + expect(stderr.join('')).not.toContain('xxx'); + }); + }); + + it('keeps machine-reporter failure fallback off stdout', async () => { + await withTempDir(async (directory: string) => { + const { options, stdout, stderr } = makeOptions(directory, { + argv: ['build', '--reporter=json'] + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.logger.info('installing Rush'); + bootstrap.externalOutputHandler?.('stdout', 'npm stdout\n', false); + bootstrap.logger.error('bootstrap failed'); + + expect(stdout).toEqual([]); + expect(stderr.join('')).toBe('installing Rush\nnpm stdout\nbootstrap failed\n'); }); }); diff --git a/libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts b/libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts new file mode 100644 index 00000000000..c3ea0071b48 --- /dev/null +++ b/libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as childProcess from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { NPM_OUTPUT_CAPTURE_SCRIPT } from '../install-run'; + +async function withTempDir(action: (directory: string) => Promise): Promise { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install-run-script-test-')); + try { + await action(directory); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } +} + +describe('install-run script integration', () => { + it('tees npm output live to the matching streams while capturing ordered records once', async () => { + await withTempDir(async (directory: string) => { + const capturePath: string = path.join(directory, 'capture.ndjson'); + await fs.promises.writeFile(capturePath, ''); + const childScript: string = [ + "process.stdout.write('stdout 1\\n');", + "setTimeout(() => process.stderr.write('stderr 1\\n'), 25);", + "setTimeout(() => process.stdout.write('stdout 2\\n'), 50);" + ].join(''); + const wrapper: childProcess.ChildProcessWithoutNullStreams = childProcess.spawn( + process.execPath, + [ + '-e', + NPM_OUTPUT_CAPTURE_SCRIPT, + process.execPath, + JSON.stringify(['-e', childScript]), + capturePath, + '0', + String(1024 * 1024), + '1', + '1' + ], + { cwd: directory } + ); + + let stdoutText: string = ''; + let stderrText: string = ''; + let sawLiveOutputBeforeClose: boolean = false; + let closed: boolean = false; + wrapper.stdout.on('data', (chunk: Buffer) => { + stdoutText += chunk.toString(); + sawLiveOutputBeforeClose ||= !closed; + }); + wrapper.stderr.on('data', (chunk: Buffer) => { + stderrText += chunk.toString(); + sawLiveOutputBeforeClose ||= !closed; + }); + const exitCode: number | null = await new Promise((resolve, reject) => { + wrapper.on('error', reject); + wrapper.on('close', (code: number | null) => { + closed = true; + resolve(code); + }); + }); + + expect(exitCode).toBe(0); + expect(sawLiveOutputBeforeClose).toBe(true); + expect(stdoutText).toBe('stdout 1\nstdout 2\n'); + expect(stderrText).toBe('stderr 1\n'); + expect( + (await fs.promises.readFile(capturePath, 'utf8')) + .trim() + .split('\n') + .map((line: string) => JSON.parse(line)) + ).toEqual([ + { stream: 'stdout', text: 'stdout 1\n', wasRendered: true }, + { stream: 'stderr', text: 'stderr 1\n', wasRendered: true }, + { stream: 'stdout', text: 'stdout 2\n', wasRendered: true } + ]); + }); + }); + + it('keeps machine-reporter stdout structured while stderr remains live', async () => { + await withTempDir(async (directory: string) => { + const capturePath: string = path.join(directory, 'capture.ndjson'); + await fs.promises.writeFile(capturePath, ''); + const wrapper: childProcess.SpawnSyncReturns = childProcess.spawnSync( + process.execPath, + [ + '-e', + NPM_OUTPUT_CAPTURE_SCRIPT, + process.execPath, + JSON.stringify([ + '-e', + "process.stdout.write('stdout\\n'); setTimeout(() => process.stderr.write('stderr\\n'), 25);" + ]), + capturePath, + '0', + String(1024 * 1024), + '0', + '1' + ], + { cwd: directory, encoding: 'utf8' } + ); + + expect(wrapper.status).toBe(0); + expect(wrapper.stdout).toBe(''); + expect(wrapper.stderr).toBe('stderr\n'); + expect( + (await fs.promises.readFile(capturePath, 'utf8')) + .trim() + .split('\n') + .map((line: string) => JSON.parse(line)) + ).toEqual([ + { stream: 'stdout', text: 'stdout\n', wasRendered: false }, + { stream: 'stderr', text: 'stderr\n', wasRendered: true } + ]); + }); + }); + + it('reports missing and invalid rush.json errors without an unhandled stack', async () => { + await withTempDir(async (directory: string) => { + const builtScriptPath: string = path.resolve(__dirname, '../../../dist/scripts/install-run-rush.js'); + const scriptPath: string = path.join(directory, 'install-run-rush.js'); + await fs.promises.copyFile(builtScriptPath, scriptPath); + await fs.promises.copyFile( + path.resolve(__dirname, '../../../dist/scripts/install-run.js'), + path.join(directory, 'install-run.js') + ); + + const missingResult: childProcess.SpawnSyncReturns = childProcess.spawnSync( + process.execPath, + [scriptPath, 'build'], + { cwd: directory, encoding: 'utf8', env: { ...process.env, RUSH_PREVIEW_VERSION: undefined } } + ); + expect(missingResult.status).toBe(1); + expect(missingResult.stderr).toContain('Error: Unable to find rush.json.'); + expect(missingResult.stderr).not.toMatch(/\n\s+at /); + + await fs.promises.writeFile(path.join(directory, 'rush.json'), '{ "rushVersion": false }\n'); + const invalidResult: childProcess.SpawnSyncReturns = childProcess.spawnSync( + process.execPath, + [scriptPath, 'build'], + { cwd: directory, encoding: 'utf8', env: { ...process.env, RUSH_PREVIEW_VERSION: undefined } } + ); + expect(invalidResult.status).toBe(1); + expect(invalidResult.stderr).toContain( + 'Error: Unable to determine the required version of Rush from rush.json' + ); + expect(invalidResult.stderr).not.toMatch(/\n\s+at /); + }); + }); +}); From 04072bd4f6c5aa94f18555d63fe44cda5239d249 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 17:05:35 +0000 Subject: [PATCH 4/5] Fix bootstrap diagnostic privacy Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- .../src/scripts/InstallRunRushBootstrap.ts | 9 +- .../rush-lib/src/scripts/install-run-rush.ts | 10 ++- libraries/rush-lib/src/scripts/install-run.ts | 80 ++++++++++++++---- .../test/InstallRunRushBootstrap.test.ts | 83 +++++++++++++++++++ .../scripts/test/InstallRunScripts.test.ts | 71 +++++++++++++++- .../rush-lib/src/utilities/npmrcUtilities.ts | 17 ++-- .../src/utilities/test/npmrcUtilities.test.ts | 34 +++++++- 7 files changed, 275 insertions(+), 29 deletions(-) diff --git a/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts index 533d0f024e1..4eb2f832b9f 100644 --- a/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts +++ b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts @@ -8,7 +8,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import type { ILogger } from '../utilities/npmrcUtilities'; +import type { ILogger, LogPrivacyClassification } from '../utilities/npmrcUtilities'; import { BOOTSTRAP_BUFFER_MAX_BYTES, BOOTSTRAP_BUFFER_TRUNCATED_EXTENSION_NAME, @@ -338,11 +338,11 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { }); this.logger = { - info: (text: string) => { + info: (text: string, privacy: LogPrivacyClassification = 'public') => { this._addEvent( { type: 'activityChanged', - privacy: 'public', + privacy, payload: { kind: 'bootstrap', text } }, { stream: this._fallbackStdoutStream, text: `${text}\n` } @@ -355,6 +355,9 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { if (this._droppedRequired > droppedRequiredBefore) { this._stderr(`${text}\n`); } + }, + warning: (text: string) => { + this._stderr(`${text}\n`); } }; this.externalOutputHandler = (stream: BootstrapStream, text: string, wasRendered: boolean) => { diff --git a/libraries/rush-lib/src/scripts/install-run-rush.ts b/libraries/rush-lib/src/scripts/install-run-rush.ts index ff6d7fbf09a..3416cd583cc 100644 --- a/libraries/rush-lib/src/scripts/install-run-rush.ts +++ b/libraries/rush-lib/src/scripts/install-run-rush.ts @@ -140,7 +140,8 @@ function _run(): void { const lockFilePath: string | undefined = process.env[INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE]; if (lockFilePath) { logger.info( - `Found ${INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE}="${lockFilePath}", installing with lockfile.` + `Found ${INSTALL_RUN_RUSH_LOCKFILE_PATH_VARIABLE}="${lockFilePath}", installing with lockfile.`, + 'local-sensitive' ); } @@ -162,7 +163,12 @@ function _run(): void { } catch (error) { const logger: ILogger = bootstrap?.logger ?? - (quiet ? { info: () => {}, error: console.error } : { info: console.log, error: console.error }); + (quiet + ? { info: () => {}, error: (text: string) => console.error(text) } + : { + info: (text: string) => console.log(text), + error: (text: string) => console.error(text) + }); logger.error(`\n\n${String(error)}\n`); } } diff --git a/libraries/rush-lib/src/scripts/install-run.ts b/libraries/rush-lib/src/scripts/install-run.ts index 1b22990f11e..47fb3c57568 100644 --- a/libraries/rush-lib/src/scripts/install-run.ts +++ b/libraries/rush-lib/src/scripts/install-run.ts @@ -468,20 +468,64 @@ function _installPackage( throw new Error(`Unable to install package: ${e}`); } finally { if (capturePath !== undefined) { - try { - _readCapturedNpmOutput(capturePath, onExternalOutput!, onExternalOutputOverflow); - } finally { - _deleteFile(capturePath); - } + finalizeCapturedNpmOutput(capturePath, logger, onExternalOutput!, onExternalOutputOverflow); } } logger.info(`Successfully installed ${name}@${version}`); } -function _readCapturedNpmOutput( +function _reportCaptureDamage(logger: ILogger, capturePath: string, detail: string): void { + const message: string = `Warning: npm output capture ${JSON.stringify(capturePath)} ${detail}`; + try { + if (logger.warning) { + logger.warning(message, 'local-sensitive'); + } else { + logger.error(message, 'local-sensitive'); + } + } catch { + try { + process.stderr.write(`${message}\n`); + } catch { + // Capture diagnostics are best-effort and must not change the install result. + } + } +} + +export function finalizeCapturedNpmOutput( capturePath: string, + logger: ILogger, onExternalOutput: (stream: 'stdout' | 'stderr', text: string, wasRendered: boolean) => void, onExternalOutputOverflow: (() => void) | undefined +): void { + let firstDamageDetail: string | undefined; + let damageCount: number = 0; + try { + _readCapturedNpmOutput(capturePath, onExternalOutput, onExternalOutputOverflow, (detail: string) => { + firstDamageDetail ??= detail; + damageCount++; + }); + } catch (error) { + firstDamageDetail ??= `could not be read: ${String(error)}.`; + damageCount++; + } + if (firstDamageDetail) { + const additionalDamage: string = + damageCount > 1 ? ` ${damageCount - 1} additional capture issue(s) were discarded.` : ''; + _reportCaptureDamage(logger, capturePath, `${firstDamageDetail}${additionalDamage}`); + } + + try { + _deleteFile(capturePath); + } catch (error) { + _reportCaptureDamage(logger, capturePath, `could not be deleted: ${String(error)}.`); + } +} + +function _readCapturedNpmOutput( + capturePath: string, + onExternalOutput: (stream: 'stdout' | 'stderr', text: string, wasRendered: boolean) => void, + onExternalOutputOverflow: (() => void) | undefined, + onCaptureDamage: (detail: string) => void ): void { const fileDescriptor: number = fs.openSync(capturePath, 'r'); const buffer: Buffer = Buffer.allocUnsafe(64 * 1024); @@ -499,12 +543,18 @@ function _readCapturedNpmOutput( const line: string = pending.slice(0, newlineIndex); pending = pending.slice(newlineIndex + 1); if (line) { - const record: { + let record: { stream?: unknown; text?: unknown; wasRendered?: unknown; overflow?: unknown; - } = JSON.parse(line); + }; + try { + record = JSON.parse(line); + } catch (error) { + onCaptureDamage(`contains a corrupt record that was discarded: ${String(error)}.`); + continue; + } if (record.overflow === true) { onExternalOutputOverflow?.(); } else if ( @@ -512,18 +562,15 @@ function _readCapturedNpmOutput( typeof record.text === 'string' ) { onExternalOutput(record.stream, record.text, record.wasRendered === true); + } else { + onCaptureDamage('contains an invalid record that was discarded.'); } } } } pending += decoder.end(); if (pending.trim()) { - const record: { overflow?: unknown } = JSON.parse(pending); - if (record.overflow === true) { - onExternalOutputOverflow?.(); - } else { - throw new Error('The npm output capture ended with an incomplete record.'); - } + onCaptureDamage('ended with a partial record that was discarded.'); } } finally { fs.closeSync(fileDescriptor); @@ -762,7 +809,10 @@ function _run(): void { process.exit(1); } - const logger: ILogger = { info: console.log, error: console.error }; + const logger: ILogger = { + info: (text: string) => console.log(text), + error: (text: string) => console.error(text) + }; runWithErrorAndStatusCode(logger, () => { const rushJsonFolder: string = findRushJsonFolder(); diff --git a/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts index b4fa2d9b735..800fd0b8123 100644 --- a/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts +++ b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts @@ -5,6 +5,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { syncNpmrc } from '../../utilities/npmrcUtilities'; import { createInstallRunRushBootstrap, type IInstallRunRushBootstrap, @@ -156,6 +157,68 @@ describe(createInstallRunRushBootstrap.name, () => { }); }); + it('classifies path-bearing installation activity as local-sensitive', async () => { + await withTempDir(async (directory: string) => { + const sourceFolder: string = path.join(directory, 'sentinel-source-npmrc'); + const targetFolder: string = path.join(directory, 'sentinel-target-npmrc'); + const lockFilePath: string = path.join(directory, 'sentinel-lockfile', 'package-lock.json'); + await fs.promises.mkdir(sourceFolder, { recursive: true }); + await fs.promises.writeFile(path.join(sourceFolder, '.npmrc'), 'registry=https://example.test\n'); + + const { options, env } = makeOptions(directory, { + argv: ['build', '--reporter=json'] + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + bootstrap.logger.info('Installing @microsoft/rush...'); + syncNpmrc({ + sourceNpmrcFolder: sourceFolder, + targetNpmrcFolder: targetFolder, + logger: bootstrap.logger, + supportEnvVarFallbackSyntax: false + }); + bootstrap.logger.info( + `Found INSTALL_RUN_RUSH_LOCKFILE_PATH="${lockFilePath}", installing with lockfile.`, + 'local-sensitive' + ); + await fs.promises.rm(path.join(sourceFolder, '.npmrc')); + syncNpmrc({ + sourceNpmrcFolder: sourceFolder, + targetNpmrcFolder: targetFolder, + logger: bootstrap.logger, + supportEnvVarFallbackSyntax: false + }); + bootstrap.prepareToRun?.(); + + const events: Record[] = readHandoff(env).records.slice(1); + const activityEvents: Record[] = events.filter( + (event: Record) => event.type === 'activityChanged' + ); + expect( + activityEvents.find( + (event: Record) => + (event.payload as { text?: string }).text === 'Installing @microsoft/rush...' + ) + ).toMatchObject({ privacy: 'public' }); + + for (const sentinelPath of [sourceFolder, targetFolder, lockFilePath]) { + const matchingEvents: Record[] = activityEvents.filter( + (event: Record) => + (event.payload as { text?: string }).text?.includes(sentinelPath) === true + ); + expect(matchingEvents.length).toBeGreaterThan(0); + expect( + matchingEvents.every((event: Record) => event.privacy === 'local-sensitive') + ).toBe(true); + } + expect( + activityEvents + .filter((event: Record) => event.privacy === 'public') + .map((event: Record) => (event.payload as { text?: string }).text) + .join('\n') + ).not.toContain(directory); + }); + }); + it('stops parsing reporter controls at the pass-through separator', async () => { await withTempDir(async (directory: string) => { expect( @@ -314,6 +377,26 @@ describe(createInstallRunRushBootstrap.name, () => { }); }); + it('keeps capture-damage warnings outside required handoff accounting', async () => { + await withTempDir(async (directory: string) => { + const { options, env, stderr } = makeOptions(directory, { + argv: ['build', '--reporter=json'], + maxBytes: 1200 + }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + for (let index: number = 0; index < 20; index++) { + bootstrap.logger.warning?.( + `Warning: npm output capture "${path.join(directory, `capture-${index}.ndjson`)}" was corrupt.`, + 'local-sensitive' + ); + } + + expect(() => bootstrap.prepareToRun?.()).not.toThrow(); + expect(readHandoff(env).records).toHaveLength(2); + expect(stderr).toHaveLength(20); + }); + }); + it('fails when the npm capture reports overflow before replay', async () => { await withTempDir(async (directory: string) => { const { options } = makeOptions(directory, { argv: ['build', '--reporter=json'] }); diff --git a/libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts b/libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts index c3ea0071b48..2b3e65e6995 100644 --- a/libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts +++ b/libraries/rush-lib/src/scripts/test/InstallRunScripts.test.ts @@ -6,7 +6,8 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { NPM_OUTPUT_CAPTURE_SCRIPT } from '../install-run'; +import type { ILogger, LogPrivacyClassification } from '../../utilities/npmrcUtilities'; +import { finalizeCapturedNpmOutput, NPM_OUTPUT_CAPTURE_SCRIPT } from '../install-run'; async function withTempDir(action: (directory: string) => Promise): Promise { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install-run-script-test-')); @@ -118,6 +119,74 @@ describe('install-run script integration', () => { }); }); + it('discards a partial capture record without failing a successful install', async () => { + await withTempDir(async (directory: string) => { + const capturePath: string = path.join(directory, 'partial-capture.ndjson'); + await fs.promises.writeFile( + capturePath, + `${JSON.stringify({ stream: 'stdout', text: 'complete\n', wasRendered: true })}\n` + + '{"stream":"stderr","text":"partial' + ); + const output: Array<{ stream: 'stdout' | 'stderr'; text: string; wasRendered: boolean }> = []; + const warnings: Array<{ text: string; privacy: LogPrivacyClassification | undefined }> = []; + const logger: ILogger = { + info: () => {}, + error: () => {}, + warning: (text: string, privacy?: LogPrivacyClassification) => { + warnings.push({ text, privacy }); + } + }; + const overflow: jest.Mock = jest.fn(); + + expect(() => + finalizeCapturedNpmOutput( + capturePath, + logger, + (stream: 'stdout' | 'stderr', text: string, wasRendered: boolean) => { + output.push({ stream, text, wasRendered }); + }, + overflow + ) + ).not.toThrow(); + + expect(output).toEqual([{ stream: 'stdout', text: 'complete\n', wasRendered: true }]); + expect(overflow).not.toHaveBeenCalled(); + expect(warnings).toEqual([ + { + text: expect.stringContaining('ended with a partial record that was discarded'), + privacy: 'local-sensitive' + } + ]); + expect(fs.existsSync(capturePath)).toBe(false); + }); + }); + + it('never replaces the npm failure when capture finalization is damaged', async () => { + await withTempDir(async (directory: string) => { + const npmError: Error = new Error('npm install failed'); + const warnings: string[] = []; + const logger: ILogger = { + info: () => {}, + error: () => {}, + warning: (text: string) => warnings.push(text) + }; + let caught: unknown; + try { + try { + throw npmError; + } finally { + finalizeCapturedNpmOutput(directory, logger, () => {}, undefined); + } + } catch (error) { + caught = error; + } + + expect(caught).toBe(npmError); + expect(warnings.length).toBeGreaterThan(0); + expect(warnings.join('\n')).toContain('could not be'); + }); + }); + it('reports missing and invalid rush.json errors without an unhandled stack', async () => { await withTempDir(async (directory: string) => { const builtScriptPath: string = path.resolve(__dirname, '../../../dist/scripts/install-run-rush.js'); diff --git a/libraries/rush-lib/src/utilities/npmrcUtilities.ts b/libraries/rush-lib/src/utilities/npmrcUtilities.ts index 7ca6febe487..8968d5d3c1a 100644 --- a/libraries/rush-lib/src/utilities/npmrcUtilities.ts +++ b/libraries/rush-lib/src/utilities/npmrcUtilities.ts @@ -6,9 +6,12 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +export type LogPrivacyClassification = 'public' | 'local-sensitive'; + export interface ILogger { - info: (string: string) => void; - error: (string: string) => void; + info: (text: string, privacy?: LogPrivacyClassification) => void; + error: (text: string, privacy?: LogPrivacyClassification) => void; + warning?: (text: string, privacy?: LogPrivacyClassification) => void; } /** @@ -267,8 +270,8 @@ interface INpmrcTrimOptions { function _copyAndTrimNpmrcFile(options: INpmrcTrimOptions): string { const { logger, sourceNpmrcPath, targetNpmrcPath } = options; - logger.info(`Transforming ${sourceNpmrcPath}`); // Verbose - logger.info(` --> "${targetNpmrcPath}"`); + logger.info(`Transforming ${sourceNpmrcPath}`, 'local-sensitive'); // Verbose + logger.info(` --> "${targetNpmrcPath}"`, 'local-sensitive'); const combinedNpmrc: string = _trimNpmrcFile(options); @@ -306,9 +309,9 @@ export function syncNpmrc(options: ISyncNpmrcOptions): string | undefined { useNpmrcPublish, logger = { // eslint-disable-next-line no-console - info: console.log, + info: (text: string) => console.log(text), // eslint-disable-next-line no-console - error: console.error + error: (text: string) => console.error(text) }, createIfMissing = false } = options; @@ -332,7 +335,7 @@ export function syncNpmrc(options: ISyncNpmrcOptions): string | undefined { }); } else if (fs.existsSync(targetNpmrcPath)) { // If the source .npmrc doesn't exist and there is one in the target, delete the one in the target - logger.info(`Deleting ${targetNpmrcPath}`); // Verbose + logger.info(`Deleting ${targetNpmrcPath}`, 'local-sensitive'); // Verbose fs.unlinkSync(targetNpmrcPath); } } catch (e) { diff --git a/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts b/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts index 3c84a54cfc9..b03f4e5b98a 100644 --- a/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts +++ b/libraries/rush-lib/src/utilities/test/npmrcUtilities.test.ts @@ -1,9 +1,41 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { trimNpmrcFileLines } from '../npmrcUtilities'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { syncNpmrc, trimNpmrcFileLines } from '../npmrcUtilities'; describe('npmrcUtilities', () => { + it('does not print privacy metadata through the default console logger', async () => { + const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'npmrc-logger-test-')); + const sourceFolder: string = path.join(directory, 'source'); + const targetFolder: string = path.join(directory, 'target'); + const logSpy: jest.SpyInstance = jest.spyOn(console, 'log').mockImplementation(() => {}); + try { + await fs.promises.mkdir(sourceFolder); + await fs.promises.writeFile(path.join(sourceFolder, '.npmrc'), 'registry=https://example.test\n'); + syncNpmrc({ + sourceNpmrcFolder: sourceFolder, + targetNpmrcFolder: targetFolder, + supportEnvVarFallbackSyntax: false + }); + await fs.promises.rm(path.join(sourceFolder, '.npmrc')); + syncNpmrc({ + sourceNpmrcFolder: sourceFolder, + targetNpmrcFolder: targetFolder, + supportEnvVarFallbackSyntax: false + }); + + expect(logSpy.mock.calls.every((call: unknown[]) => call.length === 1)).toBe(true); + expect(logSpy.mock.calls.flat().join('\n')).not.toContain('local-sensitive'); + } finally { + logSpy.mockRestore(); + await fs.promises.rm(directory, { recursive: true, force: true }); + } + }); + function runTests(supportEnvVarFallbackSyntax: boolean): void { it('handles empty input', () => { expect(trimNpmrcFileLines([], {}, supportEnvVarFallbackSyntax)).toEqual([]); From be34125a375964aad356cf38e1da29ad2409d57b Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 28 Aug 2026 17:18:33 +0000 Subject: [PATCH 5/5] Route legacy bootstrap warnings to stderr Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d6318e80-5da9-4858-a147-817e8692f10e --- .../src/scripts/InstallRunRushBootstrap.ts | 8 ++++-- .../test/InstallRunRushBootstrap.test.ts | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts index 4eb2f832b9f..c1dc3eb09e6 100644 --- a/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts +++ b/libraries/rush-lib/src/scripts/InstallRunRushBootstrap.ts @@ -513,13 +513,17 @@ class InstallRunRushBootstrap implements IInstallRunRushBootstrap { function createLegacyBootstrap(options: IInstallRunRushBootstrapOptions): IInstallRunRushBootstrap { const stdout: (text: string) => void = options.stdout ?? ((text: string) => process.stdout.write(text)); const stderr: (text: string) => void = options.stderr ?? ((text: string) => process.stderr.write(text)); + const warning: (text: string) => void = (text: string) => stderr(`${text}\n`); return { enabled: false, + // Legacy mode cannot create npm captures because it exposes no external output handler. + // Keep warning routing available so future diagnostic finalization remains stderr-only. logger: options.quiet - ? { info: () => {}, error: (text: string) => stderr(`${text}\n`) } + ? { info: () => {}, error: (text: string) => stderr(`${text}\n`), warning } : { info: (text: string) => stdout(`${text}\n`), - error: (text: string) => stderr(`${text}\n`) + error: (text: string) => stderr(`${text}\n`), + warning }, externalOutputHandler: undefined, externalOutputCaptureMaxBytes: undefined, diff --git a/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts index 800fd0b8123..3dfa051b0a9 100644 --- a/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts +++ b/libraries/rush-lib/src/scripts/test/InstallRunRushBootstrap.test.ts @@ -16,6 +16,7 @@ import { RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR, RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR } from '../generated/BootstrapProtocol'; +import { finalizeCapturedNpmOutput } from '../install-run'; async function withTempDir(action: (directory: string) => Promise): Promise { const directory: string = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install-run-rush-test-')); @@ -96,6 +97,31 @@ describe(createInstallRunRushBootstrap.name, () => { }); }); + it.each([false, true])( + 'routes future capture warnings to stderr in legacy mode when quiet is %s', + async (quiet: boolean) => { + await withTempDir(async (directory: string) => { + const { options, env, stdout, stderr } = makeOptions(directory, { quiet }); + const bootstrap: IInstallRunRushBootstrap = createInstallRunRushBootstrap(options); + const capturePath: string = path.join(directory, 'legacy-partial-capture.ndjson'); + await fs.promises.writeFile(capturePath, '{"stream":"stdout","text":"partial'); + + expect(bootstrap.enabled).toBe(false); + expect(bootstrap.externalOutputHandler).toBeUndefined(); + expect(() => + finalizeCapturedNpmOutput(capturePath, bootstrap.logger, () => {}, undefined) + ).not.toThrow(); + + expect(stderr).toHaveLength(1); + expect(stderr[0]).toContain('ended with a partial record that was discarded'); + expect(stdout).toEqual([]); + expect(env[RUSH_REPORTER_BOOTSTRAP_HANDOFF_ENV_VAR]).toBeUndefined(); + expect(env[RUSH_REPORTER_BOOTSTRAP_NONCE_ENV_VAR]).toBeUndefined(); + expect(fs.existsSync(capturePath)).toBe(false); + }); + } + ); + it('writes an ordered nonce-protected handoff for an explicit reporter', async () => { await withTempDir(async (directory: string) => { const { options, env, stdout } = makeOptions(directory, {