From afd736fddda8078fc86c406ec52267ece120c9f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 10:37:59 +0000 Subject: [PATCH 1/7] feat: warn before `apify mcp install` embeds an expiring OAuth token `apify mcp install` copies the resolved API token into the MCP client's config file. Nothing refreshes that copy, so after the OAuth device-code login (#1402) the configured server silently stops working within the hour. The command now detects a resolved token that belongs to an `oauth2` profile, warns with the actual expiry and points at `--token` with a long-lived Apify Console token. Interactive runs confirm before writing; non-interactive runs abort unless `--yes` is passed. `--token` and `APIFY_TOKEN` are unaffected, and so is the tokenless `codex` client. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011ASKmG1usGR98qpjSES6ca --- docs/reference.md | 9 ++- src/commands/mcp/install.ts | 59 +++++++++++++--- test/local/commands/mcp/install.test.ts | 92 ++++++++++++++++++++++++- 3 files changed, 148 insertions(+), 12 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index 6ba3390be..ad24937bf 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1980,12 +1980,15 @@ 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 Answer the confirmation prompts with + yes: overwrite an existing 'apify' entry, and write a token + that's about to expire. ``` diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts index e6b4aa4a3..1eb317327 100644 --- a/src/commands/mcp/install.ts +++ b/src/commands/mcp/install.ts @@ -1,13 +1,48 @@ import process from 'node:process'; -import { resolveAuth } from '../../lib/auth.js'; +import { lookUpProfile } from '../../lib/auth-file.js'; +import { type ResolvedAuth, resolveAuth } from '../../lib/auth.js'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags, YesFlag } from '../../lib/command-framework/flags.js'; import { CommandExitCodes } from '../../lib/consts.js'; +import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js'; import { clientNeedsToken, getClientHandler, isSupportedClient, SUPPORTED_CLIENTS } from '../../lib/mcp/clients.js'; import { buildMcpUrl, DEFAULT_MCP_URL } from '../../lib/mcp/url.js'; -import { error } from '../../lib/outputs.js'; +import { error, simpleLog, warning } from '../../lib/outputs.js'; + +const CONSOLE_API_TOKENS_URL = 'https://console.apify.com/settings/integrations'; + +/** + * The token this command writes into a client's config is a copy: nothing refreshes it there. An + * OAuth login's access token expires after about an hour, so the install silently stops working + * unless the user passes a long-lived API token instead. + * + * Returns false when the user declines, in which case nothing should be written. + */ +async function confirmExpiringToken(auth: ResolvedAuth, yes: boolean): Promise { + const profile = auth.profile ? lookUpProfile(auth.profile.id).profile : undefined; + if (profile?.authMethod !== 'oauth2' || !profile.expiresAt) return true; + + const remainingMs = Date.parse(profile.expiresAt) - Date.now(); + const expiry = Number.isFinite(remainingMs) + ? `in about ${Math.max(0, Math.round(remainingMs / 60_000))} minutes` + : 'within the hour'; + + warning({ + message: + `You're logged in through your Apify account, and that login's API token expires ${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 (${CONSOLE_API_TOKENS_URL}) to configure a client that keeps working.`, + }); + + return useYesNoConfirm({ + message: 'Write the expiring token to the client config anyway?', + default: false, + providedConfirmFromStdin: yes || undefined, + errorMessageForStdin: `The token from 'apify login' expires ${expiry}. Re-run with --token , or with --yes to write the expiring token anyway.`, + }); +} export class MCPInstallCommand extends ApifyCommand { static override name = 'install' as const; @@ -19,7 +54,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 writing a token that expires within the hour. 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 +85,12 @@ export class MCPInstallCommand extends ApifyCommand { }; static override flags = { - ...YesFlag(`Overwrite an existing 'apify' entry without prompting.`), + ...YesFlag( + `Answer the confirmation prompts with yes: overwrite an existing 'apify' entry, and write a token that's about to expire.`, + ), 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 +113,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 +123,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/test/local/commands/mcp/install.test.ts b/test/local/commands/mcp/install.test.ts index a7b74683c..02391e6b9 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,92 @@ 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(1); + const stderr = logMessages.error.join('\n'); + expect(stderr).toMatch(/expires in about 50 minutes/); + expect(stderr).toMatch(/--token /); + 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 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 }); From 99f72242e16f142a842ef0749fb059b3cb23eb19 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 10:45:59 +0000 Subject: [PATCH 2/7] fix(mcp install): account-scoped Console link, honest --yes, singular minute Review follow-ups: - the token hint went to a hardcoded production Console path; it now uses `getConsoleIntegrationsUrl()`, so `APIFY_CONSOLE_URL` works and an organization login is sent to its own `/organization/` route. - `--yes` now really answers both of this command's prompts. The `stdinCheckWrapper` fallback only applies in CI or without a TTY, so `--yes` in a terminal still asked. - "expires in about 1 minutes" -> "1 minute". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011ASKmG1usGR98qpjSES6ca --- src/commands/mcp/install.ts | 22 ++++++++++++++------ src/lib/mcp/file-config.ts | 4 +++- test/local/commands/mcp/install.test.ts | 27 +++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts index 1eb317327..b7c07eb1e 100644 --- a/src/commands/mcp/install.ts +++ b/src/commands/mcp/install.ts @@ -5,14 +5,13 @@ import { type ResolvedAuth, resolveAuth } from '../../lib/auth.js'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { Args } from '../../lib/command-framework/args.js'; import { Flags, YesFlag } from '../../lib/command-framework/flags.js'; +import { getConsoleIntegrationsUrl } from '../../lib/console-url.js'; import { CommandExitCodes } from '../../lib/consts.js'; import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js'; import { clientNeedsToken, getClientHandler, isSupportedClient, SUPPORTED_CLIENTS } from '../../lib/mcp/clients.js'; import { buildMcpUrl, DEFAULT_MCP_URL } from '../../lib/mcp/url.js'; import { error, simpleLog, warning } from '../../lib/outputs.js'; -const CONSOLE_API_TOKENS_URL = 'https://console.apify.com/settings/integrations'; - /** * The token this command writes into a client's config is a copy: nothing refreshes it there. An * OAuth login's access token expires after about an hour, so the install silently stops working @@ -21,25 +20,36 @@ const CONSOLE_API_TOKENS_URL = 'https://console.apify.com/settings/integrations' * Returns false when the user declines, in which case nothing should be written. */ async function confirmExpiringToken(auth: ResolvedAuth, yes: boolean): Promise { - const profile = auth.profile ? lookUpProfile(auth.profile.id).profile : undefined; + if (!auth.profile) return true; + + const { profile } = lookUpProfile(auth.profile.id); if (profile?.authMethod !== 'oauth2' || !profile.expiresAt) return true; const remainingMs = Date.parse(profile.expiresAt) - Date.now(); + const minutes = Math.max(0, Math.round(remainingMs / 60_000)); const expiry = Number.isFinite(remainingMs) - ? `in about ${Math.max(0, Math.round(remainingMs / 60_000))} minutes` + ? `in about ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}` : 'within the hour'; + // 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, + }); warning({ message: `You're logged in through your Apify account, and that login's API token expires ${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 (${CONSOLE_API_TOKENS_URL}) to configure a client that keeps working.`, + `Pass --token with an API token from Apify Console (${tokensUrl}) to configure a client that keeps working.`, }); + // `--yes` has to answer this prompt even on a TTY, where `useYesNoConfirm` would otherwise ask anyway. + if (yes) return true; + return useYesNoConfirm({ message: 'Write the expiring token to the client config anyway?', default: false, - providedConfirmFromStdin: yes || undefined, errorMessageForStdin: `The token from 'apify login' expires ${expiry}. Re-run with --token , or with --yes to write the expiring token anyway.`, }); } diff --git a/src/lib/mcp/file-config.ts b/src/lib/mcp/file-config.ts index c612f13c3..0cbfc8eba 100644 --- a/src/lib/mcp/file-config.ts +++ b/src/lib/mcp/file-config.ts @@ -16,11 +16,13 @@ interface ConfirmOverwriteOptions { } async function confirmOverwrite({ filePath, entryKey, yes }: ConfirmOverwriteOptions): Promise { + // `--yes` has to answer this prompt even on a TTY, where `useYesNoConfirm` would otherwise ask anyway. + if (yes) return true; + // Skip the existing-entry preview — it contains a bearer token we should not reprint. return useYesNoConfirm({ message: `A server entry named '${entryKey}' already exists in ${tildify(filePath)}. Overwrite it?`, default: false, - providedConfirmFromStdin: 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.test.ts b/test/local/commands/mcp/install.test.ts index 02391e6b9..02725c904 100644 --- a/test/local/commands/mcp/install.test.ts +++ b/test/local/commands/mcp/install.test.ts @@ -335,6 +335,33 @@ describe('apify mcp install', () => { 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: 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 plain API-token login is embedded without a warning', async () => { await seedLogin({ authMethod: 'token', expiresAt: null, hasRefreshToken: false }); From 1f40651ec1fb2009e523e6dbd7fa62fbe6a8d45e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 10:55:37 +0000 Subject: [PATCH 3/7] fix(mcp install): don't round a dying token down to "0 minutes" A refresh that can't reach the token endpoint hands back the stored token, which may have seconds left. Rounding that to "expires in about 0 minutes" read as reassurance; it's now "expires in under a minute", or "has already expired" when there's nothing left. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011ASKmG1usGR98qpjSES6ca --- src/commands/mcp/install.ts | 23 ++++++++++++++++------- test/local/commands/mcp/install.test.ts | 16 ++++++++++++++++ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts index b7c07eb1e..3c1f6f10b 100644 --- a/src/commands/mcp/install.ts +++ b/src/commands/mcp/install.ts @@ -12,6 +12,19 @@ import { clientNeedsToken, getClientHandler, isSupportedClient, SUPPORTED_CLIENT import { buildMcpUrl, DEFAULT_MCP_URL } from '../../lib/mcp/url.js'; import { error, simpleLog, warning } from '../../lib/outputs.js'; +/** How long the token has left, as a clause that follows the token it describes. */ +function describeExpiry(remainingMs: number): string { + if (!Number.isFinite(remainingMs)) return 'expires within the hour'; + // A refresh that could not reach the token endpoint hands back the stored token, which by then + // may have minutes or seconds left — never round that down to a reassuring "0 minutes". + if (remainingMs <= 0) return 'has already expired'; + + const minutes = Math.round(remainingMs / 60_000); + if (minutes < 1) return 'expires in under a minute'; + + return `expires in about ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`; +} + /** * The token this command writes into a client's config is a copy: nothing refreshes it there. An * OAuth login's access token expires after about an hour, so the install silently stops working @@ -25,11 +38,7 @@ async function confirmExpiringToken(auth: ResolvedAuth, yes: boolean): Promise, or with --yes to write the expiring token anyway.`, + errorMessageForStdin: `The token from 'apify login' ${expiry}. Re-run with --token , or with --yes to write the expiring token anyway.`, }); } diff --git a/test/local/commands/mcp/install.test.ts b/test/local/commands/mcp/install.test.ts index 02725c904..1a2fc692b 100644 --- a/test/local/commands/mcp/install.test.ts +++ b/test/local/commands/mcp/install.test.ts @@ -343,6 +343,22 @@ describe('apify mcp install', () => { 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' }); From 8520a3f53dfc3d3ba90bfc945e705d07b741ac38 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:05:26 +0000 Subject: [PATCH 4/7] refactor(mcp install): keep the established --yes confirmation pattern Backs out the `if (yes) return true` guards. `--yes` being ignored on a TTY comes from `stdinCheckWrapper` and affects `actors rm`, `datasets rm`, `runs rm`, `key-value-stores rm`, `key-value-stores delete-value`, `builds rm` and `builds remove-tag` too; fixing it in two of nine places only makes the CLI inconsistent. Left for a separate change, and the --yes description no longer promises more than it delivers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011ASKmG1usGR98qpjSES6ca --- docs/reference.md | 5 ++--- src/commands/mcp/install.ts | 10 +++------- src/lib/mcp/file-config.ts | 4 +--- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index ad24937bf..d567640b5 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1986,9 +1986,8 @@ FLAGS --tools= Comma-separated tool IDs or Actor full names to expose. Forwarded as a '?tools=' query parameter. --url= Apify MCP server URL. - -y, --yes Answer the confirmation prompts with - yes: overwrite an existing 'apify' entry, and write a token - that's about to expire. + -y, --yes Overwrite an existing 'apify' entry, and + accept a token that's about to expire, without prompting. ``` diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts index 3c1f6f10b..217491af4 100644 --- a/src/commands/mcp/install.ts +++ b/src/commands/mcp/install.ts @@ -53,12 +53,10 @@ async function confirmExpiringToken(auth: ResolvedAuth, yes: boolean): Promise, or with --yes to write the expiring token anyway.`, }); } @@ -73,7 +71,7 @@ export class MCPInstallCommand extends ApifyCommand { static override interactive = true; static override interactiveNote = - 'Prompts before overwriting an existing JSON config entry (cursor, kiro, antigravity), and before writing a token that expires within the hour. 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.'; + 'Prompts before overwriting an existing JSON config entry (cursor, kiro, antigravity), and before embedding a token that expires within the hour. 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 = [ { @@ -104,9 +102,7 @@ export class MCPInstallCommand extends ApifyCommand { }; static override flags = { - ...YesFlag( - `Answer the confirmation prompts with yes: overwrite an existing 'apify' entry, and write a token that's about to expire.`, - ), + ...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'. A token from an OAuth 'apify login' expires after about an hour, so pass a long-lived one from Apify Console.`, diff --git a/src/lib/mcp/file-config.ts b/src/lib/mcp/file-config.ts index 0cbfc8eba..c612f13c3 100644 --- a/src/lib/mcp/file-config.ts +++ b/src/lib/mcp/file-config.ts @@ -16,13 +16,11 @@ interface ConfirmOverwriteOptions { } async function confirmOverwrite({ filePath, entryKey, yes }: ConfirmOverwriteOptions): Promise { - // `--yes` has to answer this prompt even on a TTY, where `useYesNoConfirm` would otherwise ask anyway. - if (yes) return true; - // Skip the existing-entry preview — it contains a bearer token we should not reprint. return useYesNoConfirm({ message: `A server entry named '${entryKey}' already exists in ${tildify(filePath)}. Overwrite it?`, default: false, + providedConfirmFromStdin: yes || undefined, errorMessageForStdin: `An '${entryKey}' entry already exists in ${tildify(filePath)}. Re-run with --yes to overwrite.`, }); } From 1895c49757af3abef1c3223461d6c8450ba462e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:21:52 +0000 Subject: [PATCH 5/7] fix: make --yes skip confirmations on a TTY too `stdinCheckWrapper` only consulted `providedConfirmFromStdin` when stdin could not be prompted (CI, or no TTY), so `--yes` was silently dropped in a terminal. The new `apify mcp install` expiry prompt made that visible: unlike the overwrite prompt it fires on every OAuth-login install, so `apify mcp install cursor --yes` hung on a question it had already been answered, and its default was No. Adds a `preAnswered` option that means "the user answered on the command line" and skips the prompt everywhere, and moves every `--yes` call site onto it: `actors rm`, `datasets rm`, `runs rm`, `key-value-stores rm`, `key-value-stores delete-value`, `builds rm`, `builds remove-tag` and both `mcp install` prompts. `cli-management/install` keeps `providedConfirmFromStdin`, which it uses as a non-interactive default rather than an answer. Also bounds the expiry warning: it only fires under a day of remaining lifetime, and reports hours or days rather than a three-digit minute count, since the authorization server picks the token lifetime. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011ASKmG1usGR98qpjSES6ca --- src/commands/actors/rm.ts | 2 +- src/commands/builds/remove-tag.ts | 2 +- src/commands/builds/rm.ts | 4 +- src/commands/datasets/rm.ts | 2 +- src/commands/key-value-stores/delete-value.ts | 2 +- src/commands/key-value-stores/rm.ts | 2 +- src/commands/mcp/install.ts | 33 +++++++++--- src/commands/runs/rm.ts | 2 +- .../user-confirmations/_stdinCheckWrapper.ts | 14 ++++- src/lib/mcp/file-config.ts | 2 +- test/local/commands/mcp/install.test.ts | 21 ++++++++ .../lib/hooks/stdin-check-wrapper.test.ts | 53 +++++++++++++++++++ 12 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 test/local/lib/hooks/stdin-check-wrapper.test.ts 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/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 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({ @@ -56,7 +73,7 @@ async function confirmExpiringToken(auth: ResolvedAuth, yes: boolean): Promise, or with --yes to write the expiring token anyway.`, }); } @@ -71,7 +88,7 @@ export class MCPInstallCommand extends ApifyCommand { static override interactive = true; static override interactiveNote = - 'Prompts before overwriting an existing JSON config entry (cursor, kiro, antigravity), and before embedding a token that expires within the hour. 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.'; + '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 = [ { 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 dd2db6223..7c20456bc 100644 --- a/src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts +++ b/src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts @@ -15,6 +15,12 @@ export interface StdinCheckWrapperInput extends StdinCheckWrapperO * When set, this value will be used in environments where stdin is not available. */ providedConfirmFromStdin?: ReturnedType; + /** + * The answer the user already gave on the command line, typically through `--yes`. Unlike + * {@link providedConfirmFromStdin}, which only fills in for a stdin nobody can type into, this + * skips the prompt everywhere — a flag that promises "without prompting" has to hold on a TTY too. + */ + preAnswered?: ReturnedType; } type AllButFirst = T extends [any, ...infer Rest] ? Rest : never; @@ -47,10 +53,14 @@ export function stdinCheckWrapper any>( }: StdinCheckWrapperOptions = {}, ): (...args: NewFunctionArgs) => Promise>> { return async (input, ...rest) => { - const { isTTY, hasData } = await useStdin(); - const casted = input as StdinCheckWrapperInput>>; + if (typeof casted.preAnswered !== 'undefined') { + return casted.preAnswered; + } + + const { isTTY, hasData } = await useStdin(); + if (isCI || (!isTTY && !hasData)) { if (typeof casted.providedConfirmFromStdin === 'undefined') { throw new Error(casted.errorMessageForStdin ?? errorMessageForStdin); 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.test.ts b/test/local/commands/mcp/install.test.ts index 1a2fc692b..9233486fb 100644 --- a/test/local/commands/mcp/install.test.ts +++ b/test/local/commands/mcp/install.test.ts @@ -378,6 +378,27 @@ describe('apify mcp install', () => { 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 }); 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..4253d9e43 --- /dev/null +++ b/test/local/lib/hooks/stdin-check-wrapper.test.ts @@ -0,0 +1,53 @@ +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, hasData: false }); + +/** What it sees in a pipeline: no terminal to prompt on. */ +const nonInteractiveStdin = () => useStdinMock.mockResolvedValue({ isTTY: false, hasData: false }); + +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('prompts on a TTY when nothing was pre-answered', async () => { + interactiveStdin(); + const prompt = vitest.fn().mockResolvedValue(false); + const wrapped = stdinCheckWrapper(prompt); + + await expect(wrapped({ providedConfirmFromStdin: true })).resolves.toBe(false); + expect(prompt).toHaveBeenCalledOnce(); + }); + + it('falls back to providedConfirmFromStdin only when there is no terminal', async () => { + nonInteractiveStdin(); + const prompt = vitest.fn(); + const wrapped = stdinCheckWrapper(prompt); + + await expect(wrapped({ providedConfirmFromStdin: true })).resolves.toBe(true); + expect(prompt).not.toHaveBeenCalled(); + }); + + it('throws the caller message when there is no terminal and no answer', async () => { + nonInteractiveStdin(); + const wrapped = stdinCheckWrapper(vitest.fn()); + + await expect(wrapped({ errorMessageForStdin: 'Re-run with --yes.' })).rejects.toThrow('Re-run with --yes.'); + }); +}); From 640e81525c3c1b2b56e9fb28837f5106a9f16445 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:36:46 +0000 Subject: [PATCH 6/7] fix(mcp install): fail instead of hanging when nothing can answer the prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `useStdin` sets `hasData` for any stdin that is not a terminal — /dev/null and a closed descriptor included — so the hook's non-interactive branch never fired outside CI and the prompt waited on input that never came. A Docker build or a cron job running `apify mcp install cursor` with an OAuth login would have hung where it used to succeed. The expiry confirmation now checks for a terminal itself and exits with InvalidInput and the --token hint when there isn't one. Covered by a test file that leaves `isCI` false, which is the case that hung. The same latent hang is in the overwrite prompt and in every other `useYesNoConfirm` caller; the root cause is `useStdin.hasData`, left for a separate change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011ASKmG1usGR98qpjSES6ca --- src/commands/mcp/install.ts | 23 ++++-- .../commands/mcp/install-non-tty.test.ts | 76 +++++++++++++++++++ test/local/commands/mcp/install.test.ts | 2 +- .../lib/hooks/stdin-check-wrapper.test.ts | 30 +++----- 4 files changed, 104 insertions(+), 27 deletions(-) create mode 100644 test/local/commands/mcp/install-non-tty.test.ts diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts index b49347475..0d98cc241 100644 --- a/src/commands/mcp/install.ts +++ b/src/commands/mcp/install.ts @@ -1,5 +1,7 @@ import process from 'node:process'; +import { isCI } from 'ci-info'; + import { lookUpProfile } from '../../lib/auth-file.js'; import { type ResolvedAuth, resolveAuth } from '../../lib/auth.js'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; @@ -8,6 +10,7 @@ import { Flags, YesFlag } from '../../lib/command-framework/flags.js'; import { getConsoleIntegrationsUrl } from '../../lib/console-url.js'; import { CommandExitCodes } from '../../lib/consts.js'; import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js'; +import { useStdin } from '../../lib/hooks/useStdin.js'; import { clientNeedsToken, getClientHandler, isSupportedClient, SUPPORTED_CLIENTS } from '../../lib/mcp/clients.js'; import { buildMcpUrl, DEFAULT_MCP_URL } from '../../lib/mcp/url.js'; import { error, simpleLog, warning } from '../../lib/outputs.js'; @@ -70,12 +73,20 @@ async function confirmExpiringToken(auth: ResolvedAuth, yes: boolean): Promise, or with --yes to write the expiring token anyway.`, - }); + if (yes) 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; + throw new Error( + `The token from 'apify login' ${expiry}. Re-run with --token , or with --yes to write the expiring token anyway.`, + ); + } + + return useYesNoConfirm({ message: 'Write the expiring token to the client config anyway?', default: false }); } export class MCPInstallCommand extends ApifyCommand { 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..0177e7fb2 --- /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(/--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 9233486fb..f02d1fa68 100644 --- a/test/local/commands/mcp/install.test.ts +++ b/test/local/commands/mcp/install.test.ts @@ -303,7 +303,7 @@ describe('apify mcp install', () => { await testRunCommand(MCPInstallCommand, { args_client: 'cursor' }); - expect(process.exitCode).toBe(1); + expect(process.exitCode).toBe(CommandExitCodes.InvalidInput); const stderr = logMessages.error.join('\n'); expect(stderr).toMatch(/expires in about 50 minutes/); expect(stderr).toMatch(/--token /); diff --git a/test/local/lib/hooks/stdin-check-wrapper.test.ts b/test/local/lib/hooks/stdin-check-wrapper.test.ts index 4253d9e43..c4b3f33f4 100644 --- a/test/local/lib/hooks/stdin-check-wrapper.test.ts +++ b/test/local/lib/hooks/stdin-check-wrapper.test.ts @@ -13,9 +13,6 @@ vitest.mock('ci-info', async (importOriginal) => { /** What the wrapper sees when the user is sitting at a terminal. */ const interactiveStdin = () => useStdinMock.mockResolvedValue({ isTTY: true, hasData: false }); -/** What it sees in a pipeline: no terminal to prompt on. */ -const nonInteractiveStdin = () => useStdinMock.mockResolvedValue({ isTTY: false, hasData: false }); - describe('stdinCheckWrapper', () => { it('returns preAnswered without prompting, even on a TTY', async () => { interactiveStdin(); @@ -26,28 +23,21 @@ describe('stdinCheckWrapper', () => { expect(prompt).not.toHaveBeenCalled(); }); - it('prompts on a TTY when nothing was pre-answered', async () => { - interactiveStdin(); - const prompt = vitest.fn().mockResolvedValue(false); - const wrapped = stdinCheckWrapper(prompt); - - await expect(wrapped({ providedConfirmFromStdin: true })).resolves.toBe(false); - expect(prompt).toHaveBeenCalledOnce(); - }); - - it('falls back to providedConfirmFromStdin only when there is no terminal', async () => { - nonInteractiveStdin(); + it('answers from preAnswered before it even looks at stdin', async () => { + useStdinMock.mockRejectedValue(new Error('stdin should not be inspected')); const prompt = vitest.fn(); - const wrapped = stdinCheckWrapper(prompt); - await expect(wrapped({ providedConfirmFromStdin: true })).resolves.toBe(true); + await expect(stdinCheckWrapper(prompt)({ preAnswered: false })).resolves.toBe(false); expect(prompt).not.toHaveBeenCalled(); }); - it('throws the caller message when there is no terminal and no answer', async () => { - nonInteractiveStdin(); - const wrapped = stdinCheckWrapper(vitest.fn()); + it('still prompts on a TTY when nothing was pre-answered', async () => { + interactiveStdin(); + const prompt = vitest.fn().mockResolvedValue(false); + const wrapped = stdinCheckWrapper(prompt); - await expect(wrapped({ errorMessageForStdin: 'Re-run with --yes.' })).rejects.toThrow('Re-run with --yes.'); + // `providedConfirmFromStdin` is a fallback for a stdin nobody can type into, not an answer. + await expect(wrapped({ providedConfirmFromStdin: true })).resolves.toBe(false); + expect(prompt).toHaveBeenCalledOnce(); }); }); From 91a1dde4b5d0dc1f0b7fe9dd7816ca281436161a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:44:24 +0000 Subject: [PATCH 7/7] refactor: name the non-interactive default for what it is Follow-ups from the principal review: - after the `preAnswered` migration, `providedConfirmFromStdin` had one caller left, passing a literal `false`. It never was an answer from stdin; it's what to assume when nobody can be asked, so it's now `defaultWhenNonInteractive`. - the non-interactive expiry path printed the warning paragraph and then threw a second message repeating the expiry and the --token hint. It's one message now, since there's no prompt to follow it up with. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011ASKmG1usGR98qpjSES6ca --- src/commands/cli-management/install.ts | 2 +- src/commands/mcp/install.ts | 24 ++++++++++--------- .../user-confirmations/_stdinCheckWrapper.ts | 16 ++++++------- .../commands/mcp/install-non-tty.test.ts | 2 +- test/local/commands/mcp/install.test.ts | 3 ++- .../lib/hooks/stdin-check-wrapper.test.ts | 4 ++-- 6 files changed, 27 insertions(+), 24 deletions(-) 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/mcp/install.ts b/src/commands/mcp/install.ts index 0d98cc241..babf108f7 100644 --- a/src/commands/mcp/install.ts +++ b/src/commands/mcp/install.ts @@ -66,14 +66,15 @@ async function confirmExpiringToken(auth: ResolvedAuth, yes: boolean): Promise, or with --yes to write the expiring token anyway.`, - ); + // 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 }); } diff --git a/src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts b/src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts index 7c20456bc..2cdb064b7 100644 --- a/src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts +++ b/src/lib/hooks/user-confirmations/_stdinCheckWrapper.ts @@ -12,15 +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; /** - * The answer the user already gave on the command line, typically through `--yes`. Unlike - * {@link providedConfirmFromStdin}, which only fills in for a stdin nobody can type into, this - * skips the prompt everywhere — a flag that promises "without prompting" has to hold on a TTY too. + * 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. */ - preAnswered?: ReturnedType; + defaultWhenNonInteractive?: ReturnedType; } type AllButFirst = T extends [any, ...infer Rest] ? Rest : never; @@ -62,11 +62,11 @@ export function stdinCheckWrapper any>( const { isTTY, hasData } = await useStdin(); if (isCI || (!isTTY && !hasData)) { - 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/test/local/commands/mcp/install-non-tty.test.ts b/test/local/commands/mcp/install-non-tty.test.ts index 0177e7fb2..9783bd168 100644 --- a/test/local/commands/mcp/install-non-tty.test.ts +++ b/test/local/commands/mcp/install-non-tty.test.ts @@ -63,7 +63,7 @@ describe('apify mcp install, non-interactive but not CI', () => { await testRunCommand(MCPInstallCommand, { args_client: 'cursor' }); expect(process.exitCode).toBe(CommandExitCodes.InvalidInput); - expect(logMessages.error.join('\n')).toMatch(/--token /); + expect(logMessages.error.join('\n')).toMatch(/Pass --token with an API token/); expect(existsSync(joinPath('.cursor', 'mcp.json'))).toBe(false); }); diff --git a/test/local/commands/mcp/install.test.ts b/test/local/commands/mcp/install.test.ts index f02d1fa68..f5b0473ce 100644 --- a/test/local/commands/mcp/install.test.ts +++ b/test/local/commands/mcp/install.test.ts @@ -306,7 +306,8 @@ describe('apify mcp install', () => { expect(process.exitCode).toBe(CommandExitCodes.InvalidInput); const stderr = logMessages.error.join('\n'); expect(stderr).toMatch(/expires in about 50 minutes/); - expect(stderr).toMatch(/--token /); + 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); }); diff --git a/test/local/lib/hooks/stdin-check-wrapper.test.ts b/test/local/lib/hooks/stdin-check-wrapper.test.ts index c4b3f33f4..4e112002c 100644 --- a/test/local/lib/hooks/stdin-check-wrapper.test.ts +++ b/test/local/lib/hooks/stdin-check-wrapper.test.ts @@ -36,8 +36,8 @@ describe('stdinCheckWrapper', () => { const prompt = vitest.fn().mockResolvedValue(false); const wrapped = stdinCheckWrapper(prompt); - // `providedConfirmFromStdin` is a fallback for a stdin nobody can type into, not an answer. - await expect(wrapped({ providedConfirmFromStdin: true })).resolves.toBe(false); + // `defaultWhenNonInteractive` is what to assume when nobody can be asked, not an answer. + await expect(wrapped({ defaultWhenNonInteractive: true })).resolves.toBe(false); expect(prompt).toHaveBeenCalledOnce(); }); });