diff --git a/src/__tests__/update-check.test.ts b/src/__tests__/update-check.test.ts index 10900929b..a11c6463e 100644 --- a/src/__tests__/update-check.test.ts +++ b/src/__tests__/update-check.test.ts @@ -36,7 +36,7 @@ beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-03-31T10:00:00.000Z')); vi.stubEnv('NODE_ENV', ''); - vi.stubEnv('CI', ''); + vi.stubEnv('CI', '0'); vi.stubEnv('AGENT_DEVICE_NO_UPDATE_NOTIFIER', ''); Object.defineProperty(process.stderr, 'isTTY', { configurable: true, diff --git a/src/cli/auth-session.ts b/src/cli/auth-session.ts index 7cd850e56..b87e63c65 100644 --- a/src/cli/auth-session.ts +++ b/src/cli/auth-session.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { runCmd } from '../utils/exec.ts'; import { AppError } from '../kernel/errors.ts'; import type { CliFlags } from './parser/cli-flags.ts'; -import type { EnvMap } from '../utils/env-map.ts'; +import { isInteractive, type EnvMap } from '../utils/env-map.ts'; import { readCloudJsonResponse } from './cloud-response.ts'; const DEFAULT_CLOUD_BASE_URL = 'https://cloud.agent-device.dev'; @@ -477,15 +477,13 @@ function detectAuthMode( ): 'local-browser' | 'device-code' | 'non-interactive' { const stdinIsTTY = io?.stdinIsTTY ?? process.stdin.isTTY; const stdoutIsTTY = io?.stdoutIsTTY ?? process.stdout.isTTY; - if (isCi(env) || !stdinIsTTY || !stdoutIsTTY) return 'non-interactive'; + if (!isInteractive({ isTTY: stdinIsTTY }, env) || !isInteractive({ isTTY: stdoutIsTTY }, env)) { + return 'non-interactive'; + } if (isRemoteShell(env)) return 'device-code'; return 'local-browser'; } -function isCi(env: EnvMap): boolean { - return env.CI === 'true' || env.GITHUB_ACTIONS === 'true' || env.BUILDKITE === 'true'; -} - function isRemoteShell(env: EnvMap): boolean { return Boolean( env.SSH_TTY || diff --git a/src/daemon/client/daemon-client.ts b/src/daemon/client/daemon-client.ts index 0559e5f9a..a1a2ff397 100644 --- a/src/daemon/client/daemon-client.ts +++ b/src/daemon/client/daemon-client.ts @@ -4,6 +4,7 @@ import type { } from '../types.ts'; import type { RequestProgressSink } from '../request-progress.ts'; import { createRequestId, emitDiagnostic, withDiagnosticTimer } from '../../utils/diagnostics.ts'; +import { isInteractive } from '../../utils/env-map.ts'; import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { prepareRemoteRequestArtifacts } from '../../remote/daemon-artifacts.ts'; import { @@ -107,7 +108,8 @@ export async function sendToDaemon( } function writeInstallInProgressNotice(command: string | undefined): void { - if (!isInstallLikeCommand(command) || process.stderr.isTTY !== true || process.env.CI) return; + if (!isInstallLikeCommand(command)) return; + if (!isInteractive(process.stderr)) return; process.stderr.write( command === PUBLIC_COMMANDS.reinstall ? 'Reinstalling...\n' : 'Installing...\n', ); diff --git a/src/replay/test/__tests__/reporters-default.test.ts b/src/replay/test/__tests__/reporters-default.test.ts index 70a15d2cb..a7bad1295 100644 --- a/src/replay/test/__tests__/reporters-default.test.ts +++ b/src/replay/test/__tests__/reporters-default.test.ts @@ -54,7 +54,7 @@ function withCiEnv(value: string | undefined, run: () => T): T { } test('default replay test reporter hides and restores cursor for tty progress', () => { - withCiEnv(undefined, () => { + withCiEnv('0', () => { const reporter = createDefaultReplayTestReporter(); const { context, stderr, stdout } = createReporterContext({ stderrIsTty: true }); diff --git a/src/replay/test/reporters/default.ts b/src/replay/test/reporters/default.ts index cb162bccd..0864e93c6 100644 --- a/src/replay/test/reporters/default.ts +++ b/src/replay/test/reporters/default.ts @@ -5,6 +5,7 @@ import { REPLAY_TEST_PROGRESS_SPINNER_INTERVAL_MS, } from '../progress.ts'; import { formatDurationSeconds } from '../../../utils/duration-format.ts'; +import { isInteractive } from '../../../utils/env-map.ts'; import { colorize, supportsColor } from '../../../utils/output.ts'; import type { ReplayTestReporter, @@ -41,15 +42,16 @@ export function createDefaultReplayTestReporter(): ReplayTestReporter { ) => { stopLiveProgressInterval(); latestLiveProgressEvent = event.type === 'test-step' ? event : undefined; + const liveProgress = isInteractive(context.stderr); progressRenderer ??= createReplayTestProgressRenderer({ verbose: context.verbose, - liveProgress: shouldUseLiveProgress(context), + liveProgress, columns: context.stderr.columns, }); const output = progressRenderer.render(event); if (!output) return; context.stderr.write(output.newline ? `${output.text}\n` : output.text); - if (event.type === 'test-step' && shouldUseLiveProgress(context)) { + if (event.type === 'test-step' && liveProgress) { startLiveProgressInterval(context); } }; @@ -68,7 +70,8 @@ export function createDefaultReplayTestReporter(): ReplayTestReporter { progressInterval = undefined; }; const hideCursor = (context: ReplayTestReporterContext) => { - if (cursorHidden || !shouldUseLiveProgress(context)) return; + if (cursorHidden) return; + if (!isInteractive(context.stderr)) return; context.stderr.write(HIDE_CURSOR); cursorHidden = true; }; @@ -99,10 +102,6 @@ export function createDefaultReplayTestReporter(): ReplayTestReporter { }; } -function shouldUseLiveProgress(context: ReplayTestReporterContext): boolean { - return context.stderr.isTTY && !process.env.CI; -} - function renderReplayTestSummary( data: ReplaySuiteResult, context: ReplayTestReporterContext, diff --git a/src/upload-progress.ts b/src/upload-progress.ts index bc5560a9d..6a92bf795 100644 --- a/src/upload-progress.ts +++ b/src/upload-progress.ts @@ -1,4 +1,5 @@ import { Transform } from 'node:stream'; +import { isInteractive } from './utils/env-map.ts'; const DEFAULT_UPLOAD_PROGRESS_STEP_BYTES = 8 * 1024 * 1024; const DEFAULT_UPLOAD_PROGRESS_STEP_RATIO = 0.05; @@ -59,7 +60,7 @@ export function createUploadProgressTransform(options: { } export function createStderrUploadProgressReporter(): UploadProgressSink | undefined { - if (process.stderr.isTTY !== true || process.env.CI) return undefined; + if (!isInteractive(process.stderr)) return undefined; return (event) => { process.stderr.write(`${formatUploadProgressEvent(event)}\n`); }; diff --git a/src/utils/__tests__/daemon-client.test.ts b/src/utils/__tests__/daemon-client.test.ts index 62965e210..1d0df598f 100644 --- a/src/utils/__tests__/daemon-client.test.ts +++ b/src/utils/__tests__/daemon-client.test.ts @@ -107,7 +107,7 @@ async function captureInteractiveStderr(callback: () => Promise): Promise< const originalCi = process.env.CI; let output = ''; Object.defineProperty(stderr, 'isTTY', { configurable: true, value: true }); - delete process.env.CI; + process.env.CI = '0'; (process.stderr as any).write = ((chunk: unknown) => { output += String(chunk); return true; diff --git a/src/utils/__tests__/env-map.test.ts b/src/utils/__tests__/env-map.test.ts new file mode 100644 index 000000000..37d13d4d8 --- /dev/null +++ b/src/utils/__tests__/env-map.test.ts @@ -0,0 +1,25 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { isInteractive } from '../env-map.ts'; + +test('isInteractive requires a tty and detects CI environment markers', () => { + assert.equal(isInteractive({ isTTY: true }, {}), true); + assert.equal(isInteractive({ isTTY: false }, {}), false); + assert.equal(isInteractive({}, {}), false); + assert.equal(isInteractive({ isTTY: true }, { CI: '' }), true); + assert.equal(isInteractive({ isTTY: true }, { CI: ' ' }), false); + assert.equal(isInteractive({ isTTY: true }, { CI: 'false' }), true); + assert.equal(isInteractive({ isTTY: true }, { CI: '0' }), true); + assert.equal(isInteractive({ isTTY: true }, { CI: 'false', GITHUB_ACTIONS: 'true' }), true); + assert.equal(isInteractive({ isTTY: true }, { CI: '0', GITHUB_ACTIONS: 'true' }), true); + assert.equal(isInteractive({ isTTY: true }, { CI: 'true' }), false); + assert.equal(isInteractive({ isTTY: true }, { CI: '1' }), false); + assert.equal(isInteractive({ isTTY: true }, { BUILD_NUMBER: '123' }), false); + assert.equal(isInteractive({ isTTY: true }, { CONTINUOUS_INTEGRATION: 'true' }), false); + assert.equal(isInteractive({ isTTY: true }, { GITHUB_ACTIONS: 'true' }), false); + assert.equal(isInteractive({ isTTY: true }, { BUILDKITE: 'true' }), false); + assert.equal( + isInteractive({ isTTY: true }, { GITHUB_ACTIONS: 'false', BUILDKITE: 'false' }), + true, + ); +}); diff --git a/src/utils/env-map.ts b/src/utils/env-map.ts index 43fd717aa..ca0e716c1 100644 --- a/src/utils/env-map.ts +++ b/src/utils/env-map.ts @@ -1,2 +1,29 @@ export type EnvMap = Record; export type DefinedEnvMap = Record; +export type TtyLike = { isTTY?: boolean | undefined }; + +const CI_ENV_MARKERS = [ + 'BUILD_ID', + 'BUILD_NUMBER', + 'CI', + 'CI_APP_ID', + 'CI_BUILD_ID', + 'CI_BUILD_NUMBER', + 'CI_NAME', + 'CONTINUOUS_INTEGRATION', + 'RUN_ID', +] as const; + +const CI_TRUE_MARKERS = ['GITHUB_ACTIONS', 'BUILDKITE'] as const; + +function isCI(env: EnvMap = process.env): boolean { + if (env.CI === 'false' || env.CI === '0') return false; + return ( + CI_ENV_MARKERS.some((name) => Boolean(env[name])) || + CI_TRUE_MARKERS.some((name) => env[name] === 'true') + ); +} + +export function isInteractive(stream: TtyLike, env: EnvMap = process.env): boolean { + return !isCI(env) && stream.isTTY === true; +} diff --git a/src/utils/update-check.ts b/src/utils/update-check.ts index 61868d409..c57d6ab73 100644 --- a/src/utils/update-check.ts +++ b/src/utils/update-check.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { runCmdDetached } from './exec.ts'; +import { isInteractive } from './env-map.ts'; const PACKAGE_NAME = 'agent-device'; const UPDATE_CHECK_INTERVAL_MS = 14 * 24 * 60 * 60 * 1000; @@ -82,10 +83,9 @@ function shouldEnableUpgradeNotifier(options: UpgradeNotifierOptions): boolean { if (!options.command) return false; if (options.command === 'help' || options.command === 'test') return false; if (options.flags.help || options.flags.version || options.flags.json) return false; - if (process.env.CI?.trim()) return false; if (process.env.NODE_ENV === 'test') return false; if (process.env.AGENT_DEVICE_NO_UPDATE_NOTIFIER?.trim()) return false; - return Boolean(process.stderr.isTTY); + return isInteractive(process.stderr); } function shouldShowUpgradeNotice(cache: UpdateCheckCache, currentVersion: string): boolean {