diff --git a/docs/reference.md b/docs/reference.md index 6d7027766..1693b9e23 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1984,12 +1984,14 @@ ARGUMENTS FLAGS -t, --token= Apify API token to embed in the config. - Defaults to APIFY_TOKEN, then the token from 'apify login'. + Defaults to APIFY_TOKEN, then the token from 'apify login'. A + token from an OAuth 'apify login' expires after about an + hour, so pass a long-lived one from Apify Console. --tools= Comma-separated tool IDs or Actor full names to expose. Forwarded as a '?tools=' query parameter. --url= Apify MCP server URL. - -y, --yes Overwrite an existing 'apify' entry - without prompting. + -y, --yes Overwrite an existing 'apify' entry, and + accept a token that's about to expire, without prompting. ``` diff --git a/src/commands/actors/rm.ts b/src/commands/actors/rm.ts index 81ccb372f..c15106b99 100644 --- a/src/commands/actors/rm.ts +++ b/src/commands/actors/rm.ts @@ -54,7 +54,7 @@ export class ActorsRmCommand extends ApifyCommand { const confirmedDelete = await useYesNoConfirm({ message: `Are you sure you want to delete this Actor?`, - providedConfirmFromStdin: yes || undefined, + preAnswered: yes || undefined, }); if (!confirmedDelete) { diff --git a/src/commands/builds/remove-tag.ts b/src/commands/builds/remove-tag.ts index 6256798ce..4b467df90 100644 --- a/src/commands/builds/remove-tag.ts +++ b/src/commands/builds/remove-tag.ts @@ -84,7 +84,7 @@ export class BuildsRemoveTagCommand extends ApifyCommand { message: `Are you sure you want to delete this Actor Build? If so, please type in "${confirmationPrompt}":`, expectedValue: confirmationPrompt, failureMessage: 'Your provided value does not match the build tag.', - providedConfirmFromStdin: yes ? confirmationPrompt : undefined, + preAnswered: yes ? confirmationPrompt : undefined, }); } else { confirmed = await useYesNoConfirm({ message: `Are you sure you want to delete this Actor Build?`, - providedConfirmFromStdin: yes || undefined, + preAnswered: yes || undefined, }); } diff --git a/src/commands/cli-management/install.ts b/src/commands/cli-management/install.ts index 621ee6fbc..80a91233f 100644 --- a/src/commands/cli-management/install.ts +++ b/src/commands/cli-management/install.ts @@ -263,7 +263,7 @@ export class InstallCommand extends ApifyCommand { allowedToAutomaticallyDo = await useYesNoConfirm({ message: confirmMessage, // For now, no stdin -> always false - providedConfirmFromStdin: false, + defaultWhenNonInteractive: false, }); } diff --git a/src/commands/datasets/rm.ts b/src/commands/datasets/rm.ts index 2e5de990c..a01a974c0 100644 --- a/src/commands/datasets/rm.ts +++ b/src/commands/datasets/rm.ts @@ -59,7 +59,7 @@ export class DatasetsRmCommand extends ApifyCommand { const confirmed = await useYesNoConfirm({ message: `Are you sure you want to delete this Dataset?`, - providedConfirmFromStdin: yes || undefined, + preAnswered: yes || undefined, }); if (!confirmed) { diff --git a/src/commands/key-value-stores/delete-value.ts b/src/commands/key-value-stores/delete-value.ts index a05439f30..7508092bb 100644 --- a/src/commands/key-value-stores/delete-value.ts +++ b/src/commands/key-value-stores/delete-value.ts @@ -73,7 +73,7 @@ export class KeyValueStoresDeleteValueCommand extends ApifyCommand { + if (!auth.profile) return true; + + const { profile } = lookUpProfile(auth.profile.id); + if (profile?.authMethod !== 'oauth2' || !profile.expiresAt) return true; + + // An unparseable expiry is not evidence of a short-lived token, and `getAccessToken` ignores it too. + const remainingMs = Date.parse(profile.expiresAt) - Date.now(); + if (!Number.isFinite(remainingMs) || remainingMs > EXPIRY_WARNING_THRESHOLD_MS) return true; + + const expiry = describeExpiry(remainingMs); + // The Console account the token has to be created under: a bare URL would act on whatever + // account the browser was last on, which for an organization login is the wrong one. + const tokensUrl = getConsoleIntegrationsUrl({ + id: auth.profile.id, + organizationOwnerUserId: profile.organizationOwnerUserId, + }); + + const explanation = + `You're logged in through your Apify account, and that login's API token ${expiry}. ` + + `The MCP client reads the token from its config file and can't refresh it, so the server stops working once the token expires.\n` + + `Pass --token with an API token from Apify Console (${tokensUrl}) to configure a client that keeps working.`; + + if (yes) { + warning({ message: explanation }); + return true; + } + + // `useStdin` reports `hasData` for any stdin that is not a terminal, /dev/null and a closed + // descriptor included, so the prompt hook would ask a question nobody can answer and then wait + // on input that never comes. A Docker build or a cron job has to fail with the hint instead. + const { isTTY } = await useStdin(); + if (isCI || !isTTY) { + process.exitCode = CommandExitCodes.InvalidInput; + // The whole explanation, as one message: there is no prompt to follow it up with. + throw new Error(`${explanation}\nRe-run with --yes to write the expiring token anyway.`); + } + + warning({ message: explanation }); + + return useYesNoConfirm({ message: 'Write the expiring token to the client config anyway?', default: false }); +} export class MCPInstallCommand extends ApifyCommand { static override name = 'install' as const; @@ -19,7 +101,7 @@ export class MCPInstallCommand extends ApifyCommand { static override interactive = true; static override interactiveNote = - 'Prompts before overwriting an existing JSON config entry (cursor, kiro, antigravity). Pass --yes to overwrite without prompting. For clients delegated to their own CLI (claude-code, vscode, vscode-insiders, codex), overwrite behavior is controlled by that CLI.'; + 'Prompts before overwriting an existing JSON config entry (cursor, kiro, antigravity), and before embedding a token that expires within a day. Pass --yes to confirm without prompting. For clients delegated to their own CLI (claude-code, vscode, vscode-insiders, codex), overwrite behavior is controlled by that CLI.'; static override examples = [ { @@ -50,10 +132,10 @@ export class MCPInstallCommand extends ApifyCommand { }; static override flags = { - ...YesFlag(`Overwrite an existing 'apify' entry without prompting.`), + ...YesFlag(`Overwrite an existing 'apify' entry, and accept a token that's about to expire, without prompting.`), token: Flags.string({ char: 't', - description: `Apify API token to embed in the config. Defaults to APIFY_TOKEN, then the token from 'apify login'.`, + description: `Apify API token to embed in the config. Defaults to APIFY_TOKEN, then the token from 'apify login'. A token from an OAuth 'apify login' expires after about an hour, so pass a long-lived one from Apify Console.`, }), url: Flags.string({ description: 'Apify MCP server URL.', @@ -76,9 +158,9 @@ export class MCPInstallCommand extends ApifyCommand { return; } - let token = ''; - if (clientNeedsToken(client)) { - const auth = tokenFlag ? { token: tokenFlag } : await resolveAuth(); + let token = tokenFlag ?? ''; + if (clientNeedsToken(client) && !tokenFlag) { + const auth = await resolveAuth(); if (!auth) { error({ message: `You are not logged in to Apify. Run 'apify login' first, or pass --token .`, @@ -86,6 +168,12 @@ export class MCPInstallCommand extends ApifyCommand { process.exitCode = CommandExitCodes.MissingAuth; return; } + + if (!(await confirmExpiringToken(auth, yes))) { + simpleLog({ message: 'No changes written.' }); + return; + } + token = auth.token; } diff --git a/src/commands/runs/rm.ts b/src/commands/runs/rm.ts index 07675d224..a5fd83e7a 100644 --- a/src/commands/runs/rm.ts +++ b/src/commands/runs/rm.ts @@ -71,7 +71,7 @@ export class RunsRmCommand extends ApifyCommand { const confirmedDelete = await useYesNoConfirm({ message: `Are you sure you want to delete this Actor Run?`, - providedConfirmFromStdin: yes || undefined, + preAnswered: yes || undefined, }); if (!confirmedDelete) { diff --git a/src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts b/src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts index 875b785c5..fc22e5475 100644 --- a/src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts +++ b/src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts @@ -12,9 +12,15 @@ export const promptContext = { output: process.stderr }; export interface StdinCheckWrapperInput extends StdinCheckWrapperOptions { /** - * When set, this value will be used in environments where stdin is not available. + * The answer the user already gave on the command line, typically through `--yes`. Skips the + * prompt everywhere, a TTY included: a flag that promises "without prompting" has to hold there too. */ - providedConfirmFromStdin?: ReturnedType; + preAnswered?: ReturnedType; + /** + * What to assume when there is nobody to ask. Unlike {@link preAnswered} this is not the user's + * answer, so it never suppresses a prompt someone could actually have answered. + */ + defaultWhenNonInteractive?: ReturnedType; } type AllButFirst = T extends [any, ...infer Rest] ? Rest : never; @@ -47,18 +53,22 @@ export function stdinCheckWrapper any>( }: StdinCheckWrapperOptions = {}, ): (...args: NewFunctionArgs) => Promise>> { return async (input, ...rest) => { - const { isTTY } = await useStdin(); - const casted = input as StdinCheckWrapperInput>>; + if (typeof casted.preAnswered !== 'undefined') { + return casted.preAnswered; + } + + const { isTTY } = await useStdin(); + // Prompts need a terminal to read the answer from. Piped stdin is command input, not an // answer source. if (isCI || !isTTY) { - if (typeof casted.providedConfirmFromStdin === 'undefined') { + if (typeof casted.defaultWhenNonInteractive === 'undefined') { throw new Error(casted.errorMessageForStdin ?? errorMessageForStdin); } - return casted.providedConfirmFromStdin; + return casted.defaultWhenNonInteractive; } const res = await fn(input, ...rest); diff --git a/src/lib/mcp/file-config.ts b/src/lib/mcp/file-config.ts index c612f13c3..0cf1e233f 100644 --- a/src/lib/mcp/file-config.ts +++ b/src/lib/mcp/file-config.ts @@ -20,7 +20,7 @@ async function confirmOverwrite({ filePath, entryKey, yes }: ConfirmOverwriteOpt return useYesNoConfirm({ message: `A server entry named '${entryKey}' already exists in ${tildify(filePath)}. Overwrite it?`, default: false, - providedConfirmFromStdin: yes || undefined, + preAnswered: yes || undefined, errorMessageForStdin: `An '${entryKey}' entry already exists in ${tildify(filePath)}. Re-run with --yes to overwrite.`, }); } diff --git a/test/local/commands/mcp/install-non-tty.test.ts b/test/local/commands/mcp/install-non-tty.test.ts new file mode 100644 index 000000000..9783bd168 --- /dev/null +++ b/test/local/commands/mcp/install-non-tty.test.ts @@ -0,0 +1,76 @@ +import { existsSync } from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import process from 'node:process'; + +// Deliberately NOT mocking `isCI`: a Docker build or a cron job is non-interactive without being +// CI, and `useStdin` reports `hasData` for such a stdin, so the expiry prompt used to hang there. +vitest.mock('ci-info', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, isCI: false }; +}); + +import { MCPInstallCommand } from '../../../../src/commands/mcp/install.js'; +import { testRunCommand } from '../../../../src/lib/command-framework/apify-command.js'; +import { AUTH_FILE_PATH, CommandExitCodes, GLOBAL_CONFIGS_FOLDER } from '../../../../src/lib/consts.js'; +import { setSecret } from '../../../../src/lib/credentials.js'; +import { TEST_USER_ID, v2AuthFile } from '../../../__setup__/auth-file.js'; +import { useAuthSetup } from '../../../__setup__/hooks/useAuthSetup.js'; +import { useConsoleSpy } from '../../../__setup__/hooks/useConsoleSpy.js'; +import { useTempPath } from '../../../__setup__/hooks/useTempPath.js'; + +const STORED_TOKEN = 'apify_api_STORED_zzzzzzzzzzzzzzzzzzzz'; + +const { tmpPath, joinPath, beforeAllCalls, afterAllCalls } = useTempPath('mcp-install-non-tty'); + +useAuthSetup(); + +const { logMessages } = useConsoleSpy(); + +beforeAll(beforeAllCalls); +afterAll(afterAllCalls); + +beforeEach(async () => { + vitest.stubEnv('HOME', tmpPath); + vitest.stubEnv('APIFY_TOKEN', ''); + + await mkdir(GLOBAL_CONFIGS_FOLDER(), { recursive: true }); + await writeFile( + AUTH_FILE_PATH(), + JSON.stringify( + v2AuthFile({ + authMethod: 'oauth2', + expiresAt: new Date(Date.now() + 50 * 60_000).toISOString(), + hasRefreshToken: true, + oauth: { + issuer: 'https://issuer.test', + clientId: 'https://client.test/oauth-client.json', + tokenEndpoint: 'https://issuer.test/oauth/apps/token', + refreshTokenExpiresAt: new Date(Date.now() + 60 * 24 * 60 * 60_000).toISOString(), + }, + }), + ), + 'utf-8', + ); + await setSecret(TEST_USER_ID, 'token', STORED_TOKEN); +}); + +afterEach(() => { + process.exitCode = undefined; +}); + +describe('apify mcp install, non-interactive but not CI', () => { + it('fails with the --token hint instead of waiting on a prompt nobody can answer', async () => { + await testRunCommand(MCPInstallCommand, { args_client: 'cursor' }); + + expect(process.exitCode).toBe(CommandExitCodes.InvalidInput); + expect(logMessages.error.join('\n')).toMatch(/Pass --token with an API token/); + expect(existsSync(joinPath('.cursor', 'mcp.json'))).toBe(false); + }); + + it('--yes writes the config without prompting', async () => { + await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_yes: true }); + + expect(process.exitCode).toBeUndefined(); + expect(existsSync(joinPath('.cursor', 'mcp.json'))).toBe(true); + }); +}); diff --git a/test/local/commands/mcp/install.test.ts b/test/local/commands/mcp/install.test.ts index a7b74683c..f5b0473ce 100644 --- a/test/local/commands/mcp/install.test.ts +++ b/test/local/commands/mcp/install.test.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'node:fs'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import process from 'node:process'; @@ -11,8 +12,11 @@ vitest.mock('ci-info', async (importOriginal) => { }); import { MCPInstallCommand } from '../../../../src/commands/mcp/install.js'; +import type { AuthProfile } from '../../../../src/lib/auth-file.js'; import { testRunCommand } from '../../../../src/lib/command-framework/apify-command.js'; -import { CommandExitCodes } from '../../../../src/lib/consts.js'; +import { AUTH_FILE_PATH, CommandExitCodes, GLOBAL_CONFIGS_FOLDER } from '../../../../src/lib/consts.js'; +import { setSecret } from '../../../../src/lib/credentials.js'; +import { TEST_USER_ID, v2AuthFile } from '../../../__setup__/auth-file.js'; import { useAuthSetup } from '../../../__setup__/hooks/useAuthSetup.js'; import { useConsoleSpy } from '../../../__setup__/hooks/useConsoleSpy.js'; import { useTempPath } from '../../../__setup__/hooks/useTempPath.js'; @@ -272,6 +276,157 @@ describe('apify mcp install', () => { expect(stderr).not.toContain(TEST_TOKEN); }); + describe('expiring OAuth token', () => { + const STORED_TOKEN = 'apify_api_STORED_zzzzzzzzzzzzzzzzzzzz'; + + /** A stored login, written straight to disk so the test needs no Apify API round trip. */ + async function seedLogin(profile: Partial) { + await mkdir(GLOBAL_CONFIGS_FOLDER(), { recursive: true }); + await writeFile(AUTH_FILE_PATH(), JSON.stringify(v2AuthFile(profile)), 'utf-8'); + await setSecret(TEST_USER_ID, 'token', STORED_TOKEN); + } + + const oauthProfile = (expiresInMs: number): Partial => ({ + authMethod: 'oauth2', + expiresAt: new Date(Date.now() + expiresInMs).toISOString(), + hasRefreshToken: true, + oauth: { + issuer: 'https://issuer.test', + clientId: 'https://client.test/oauth-client.json', + tokenEndpoint: 'https://issuer.test/oauth/apps/token', + refreshTokenExpiresAt: new Date(Date.now() + 60 * 24 * 60 * 60_000).toISOString(), + }, + }); + + it('cursor: refuses to embed an OAuth token in a non-TTY without --yes', async () => { + await seedLogin(oauthProfile(50 * 60_000)); + + await testRunCommand(MCPInstallCommand, { args_client: 'cursor' }); + + expect(process.exitCode).toBe(CommandExitCodes.InvalidInput); + const stderr = logMessages.error.join('\n'); + expect(stderr).toMatch(/expires in about 50 minutes/); + expect(stderr).toMatch(/Pass --token with an API token/); + expect(stderr).toMatch(/--yes to write the expiring token anyway/); + expect(existsSync(joinPath('.cursor', 'mcp.json'))).toBe(false); + expect(stderr).not.toContain(STORED_TOKEN); + }); + + it('cursor: --yes writes the OAuth token but warns that it expires', async () => { + await seedLogin(oauthProfile(50 * 60_000)); + + await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_yes: true }); + + expect(process.exitCode).toBeUndefined(); + const config = await readJson(joinPath('.cursor', 'mcp.json')); + expect((config.mcpServers as { apify: { headers: { Authorization: string } } }).apify.headers.Authorization).toBe( + `Bearer ${STORED_TOKEN}`, + ); + const stderr = logMessages.error.join('\n'); + expect(stderr).toMatch(/Warning:.*expires in about 50 minutes/s); + expect(stderr).toMatch(/console\.apify\.com\/settings\/integrations/); + }); + + it('cursor: a token minutes from expiry is reported in minutes, not rounded up to an hour', async () => { + // Above TOKEN_REFRESH_SKEW_MS, so resolveAuth hands the token over without a refresh. + await seedLogin(oauthProfile(2 * 60_000)); + + await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_yes: true }); + + expect(logMessages.error.join('\n')).toMatch(/expires in about 2 minutes/); + }); + + it('cursor: a single remaining minute is worded in the singular', async () => { + await seedLogin(oauthProfile(75_000)); + + await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_yes: true }); + + expect(logMessages.error.join('\n')).toMatch(/expires in about 1 minute\b/); + }); + + it('cursor: a token seconds from expiry is not rounded down to a reassuring zero', async () => { + // Under TOKEN_REFRESH_SKEW_MS, so resolveAuth tries to refresh; the failed refresh hands the + // stored token back because it has not expired yet. That is the path that used to print "0 minutes". + const fetchSpy = vitest.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline')); + await seedLogin(oauthProfile(30_000)); + await setSecret(TEST_USER_ID, 'refresh-token', 'rt_1'); + + await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_yes: true }); + + expect(fetchSpy).toHaveBeenCalled(); + const stderr = logMessages.error.join('\n'); + expect(stderr).toMatch(/expires in under a minute/); + expect(stderr).not.toMatch(/0 minutes/); + fetchSpy.mockRestore(); + }); + + it("cursor: an organization login is pointed at that organization's Console tokens page", async () => { + await seedLogin({ ...oauthProfile(50 * 60_000), organizationOwnerUserId: 'owner-uid' }); + + await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_yes: true }); + + expect(logMessages.error.join('\n')).toContain( + `https://console.apify.com/organization/${TEST_USER_ID}/settings/integrations`, + ); + }); + + it('cursor: APIFY_CONSOLE_URL is honoured in the hint', async () => { + vitest.stubEnv('APIFY_CONSOLE_URL', 'https://console.staging.test'); + await seedLogin(oauthProfile(50 * 60_000)); + + await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_yes: true }); + + expect(logMessages.error.join('\n')).toContain('https://console.staging.test/settings/integrations'); + }); + + it('cursor: a token with days left is embedded silently - the warning is for short-lived ones', async () => { + await seedLogin(oauthProfile(3 * 24 * 60 * 60_000)); + + await testRunCommand(MCPInstallCommand, { args_client: 'cursor' }); + + expect(process.exitCode).toBeUndefined(); + const config = await readJson(joinPath('.cursor', 'mcp.json')); + expect((config.mcpServers as { apify: { headers: { Authorization: string } } }).apify.headers.Authorization).toBe( + `Bearer ${STORED_TOKEN}`, + ); + expect(logMessages.error.join('\n')).not.toMatch(/expires in/); + }); + + it('cursor: hours are reported in hours rather than a three-digit minute count', async () => { + await seedLogin(oauthProfile(5 * 60 * 60_000)); + + await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_yes: true }); + + expect(logMessages.error.join('\n')).toMatch(/expires in about 5 hours/); + }); + + it('cursor: a plain API-token login is embedded without a warning', async () => { + await seedLogin({ authMethod: 'token', expiresAt: null, hasRefreshToken: false }); + + await testRunCommand(MCPInstallCommand, { args_client: 'cursor' }); + + expect(process.exitCode).toBeUndefined(); + const config = await readJson(joinPath('.cursor', 'mcp.json')); + expect((config.mcpServers as { apify: { headers: { Authorization: string } } }).apify.headers.Authorization).toBe( + `Bearer ${STORED_TOKEN}`, + ); + expect(logMessages.error.join('\n')).not.toMatch(/expires in about/); + }); + + it('cursor: --token wins over a stored OAuth login, so no expiry prompt', async () => { + await seedLogin(oauthProfile(50 * 60_000)); + + await testRunCommand(MCPInstallCommand, { args_client: 'cursor', flags_token: TEST_TOKEN }); + + expect(process.exitCode).toBeUndefined(); + const config = await readJson(joinPath('.cursor', 'mcp.json')); + expect((config.mcpServers as { apify: { headers: { Authorization: string } } }).apify.headers.Authorization).toBe( + `Bearer ${TEST_TOKEN}`, + ); + expect(logMessages.error.join('\n')).not.toMatch(/expires in about/); + }); + }); + it('codex: emits a friendly error with the TOML snippet (no token leak) when codex is not on PATH', async () => { await testRunCommand(MCPInstallCommand, { args_client: 'codex', flags_token: TEST_TOKEN }); diff --git a/test/local/lib/hooks/stdin-check-wrapper.test.ts b/test/local/lib/hooks/stdin-check-wrapper.test.ts new file mode 100644 index 000000000..a1b71f8ec --- /dev/null +++ b/test/local/lib/hooks/stdin-check-wrapper.test.ts @@ -0,0 +1,64 @@ +import { stdinCheckWrapper } from '../../../../src/lib/hooks/user-confirmations/_stdinCheckWrapper.js'; + +const useStdinMock = vitest.hoisted(() => vitest.fn()); + +vitest.mock('../../../../src/lib/hooks/useStdin.js', () => ({ useStdin: useStdinMock })); + +// The wrapper consults `isCI` before stdin, so an interactive terminal has to be mocked on both axes. +vitest.mock('ci-info', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, isCI: false }; +}); + +/** What the wrapper sees when the user is sitting at a terminal. */ +const interactiveStdin = () => useStdinMock.mockResolvedValue({ isTTY: true }); + +/** What the wrapper sees under `cmd | apify ...`: input to read, but nobody to answer a prompt. */ +const pipedStdin = () => useStdinMock.mockResolvedValue({ isTTY: undefined }); + +describe('stdinCheckWrapper', () => { + it('returns preAnswered without prompting, even on a TTY', async () => { + interactiveStdin(); + const prompt = vitest.fn(); + const wrapped = stdinCheckWrapper(prompt); + + await expect(wrapped({ preAnswered: true })).resolves.toBe(true); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('answers from preAnswered before it even looks at stdin', async () => { + useStdinMock.mockRejectedValue(new Error('stdin should not be inspected')); + const prompt = vitest.fn(); + + await expect(stdinCheckWrapper(prompt)({ preAnswered: false })).resolves.toBe(false); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('still prompts on a TTY when nothing was pre-answered', async () => { + interactiveStdin(); + const prompt = vitest.fn().mockResolvedValue(false); + const wrapped = stdinCheckWrapper(prompt); + + // `defaultWhenNonInteractive` is what to assume when nobody can be asked, not an answer. + await expect(wrapped({ defaultWhenNonInteractive: true })).resolves.toBe(false); + expect(prompt).toHaveBeenCalledOnce(); + }); + + it('falls back to defaultWhenNonInteractive on piped stdin instead of prompting', async () => { + pipedStdin(); + const prompt = vitest.fn(); + const wrapped = stdinCheckWrapper(prompt); + + await expect(wrapped({ defaultWhenNonInteractive: true })).resolves.toBe(true); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('throws the stdin hint on piped stdin when there is no default to fall back on', async () => { + pipedStdin(); + const prompt = vitest.fn(); + const wrapped = stdinCheckWrapper(prompt, { errorMessageForStdin: 'pass --confirm' }); + + await expect(wrapped({})).rejects.toThrow('pass --confirm'); + expect(prompt).not.toHaveBeenCalled(); + }); +});