diff --git a/src/commands/info.ts b/src/commands/info.ts index 63c78402d..0eef30faa 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import { ApifyCommand } from '../lib/command-framework/apify-command.js'; -import { getLocalUserInfo, getLoggedClientOrThrow } from '../lib/utils.js'; +import { getLocalUserInfo, getLoggedClientOrThrow, printJsonToStdout } from '../lib/utils.js'; export class InfoCommand extends ApifyCommand { static override name = 'info' as const; @@ -15,13 +15,30 @@ export class InfoCommand extends ApifyCommand { description: 'Print the currently logged-in account username and user ID.', command: 'apify info', }, + { + description: 'Print the currently logged-in account as JSON.', + command: 'apify info --json', + }, ]; static override docsUrl = 'https://docs.apify.com/cli/docs/reference#apify-info'; + static override enableJsonFlag = true; + async run() { await getLoggedClientOrThrow(); const info = await getLocalUserInfo(); + const { json } = this.flags; + + if (json) { + // Never leak the raw API token in --json output — it's a secret and scripts routinely + // log their JSON responses. Emit only the safe identity fields. + printJsonToStdout({ + username: info?.username ?? null, + userId: info?.id ?? null, + }); + return; + } if (info) { const niceInfo = { diff --git a/src/entrypoints/_shared.ts b/src/entrypoints/_shared.ts index 501469d7d..4323e5c83 100644 --- a/src/entrypoints/_shared.ts +++ b/src/entrypoints/_shared.ts @@ -11,6 +11,7 @@ import { CommandError } from '../lib/command-framework/CommandError.js'; import { renderMainHelpMenu } from '../lib/command-framework/help.js'; import { readStdin } from '../lib/commands/read-stdin.js'; import { SUPPORTED_NODEJS_VERSION } from '../lib/consts.js'; +import { AuthError } from '../lib/errors/AuthError.js'; import { useCLIMetadata } from '../lib/hooks/useCLIMetadata.js'; import { shouldSkipVersionCheck } from '../lib/hooks/useCLIVersionCheck.js'; import { useCommandSuggestions } from '../lib/hooks/useCommandSuggestions.js'; @@ -248,10 +249,40 @@ export async function runCLI(entrypoint: string) { cliDebugPrint('CommandArgsResult', commandResult); } catch (err) { + // Auth failures deserve a machine-readable envelope when the caller opted into + // structured output with --json; otherwise scripts piping `apify ... --json | jq` + // blow up on the human-facing prose that would normally go to stderr. + if (err instanceof AuthError && jsonFlagRequested(FinalCommand, rebuiltArgs)) { + const envelope = { + error: { + type: err.type, + message: err.message, + exitCode: err.exitCode, + }, + }; + + console.log(JSON.stringify(envelope, null, 2)); + process.exit(err.exitCode); + } + const commandError = CommandError.into(err, FinalCommand); error({ message: commandError.getPrettyMessage() }); - process.exit(1); + // AuthError sets process.exitCode via CommandExitCodes.MissingAuth upstream — surface it + // (rather than the hard-coded 1) so callers can distinguish auth failures from other errors. + const exitCode = err instanceof AuthError ? err.exitCode : 1; + process.exit(exitCode); } } + +/** + * Best-effort check for whether `--json` was requested for this invocation. We can't rely on + * `instance.flags.json` because errors can be thrown from `parseArgs` itself (before flags are + * populated) or from inside `_run` before the flags object is fully hydrated. Scanning the raw + * argv slice is cheap and matches the user's actual intent to receive structured output. + */ +function jsonFlagRequested(command: typeof BuiltApifyCommand, args: string[]): boolean { + if (!command.enableJsonFlag) return false; + return args.includes('--json') || args.includes('--json=true'); +} diff --git a/src/lib/errors/AuthError.ts b/src/lib/errors/AuthError.ts new file mode 100644 index 000000000..fcbdaa71a --- /dev/null +++ b/src/lib/errors/AuthError.ts @@ -0,0 +1,23 @@ +import { CommandExitCodes } from '../consts.js'; + +/** + * Thrown when an Apify command requires authentication but the user is not logged in + * (or the stored credentials are unusable). + * + * The top-level CLI handler uses this class to decide between: + * - printing a human-friendly "please run apify login" message on stderr, or + * - emitting a machine-readable JSON envelope on stdout when the invocation + * set `--json` (so scripts that pipe `apify ... --json | jq ...` see a + * parseable object instead of prose). + */ +export class AuthError extends Error { + public readonly type = 'AuthError' as const; + + public readonly exitCode: number; + + public constructor(message: string, exitCode: number = CommandExitCodes.MissingAuth) { + super(message); + this.name = 'AuthError'; + this.exitCode = exitCode; + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index b129d369c..b70d9ae06 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -45,6 +45,7 @@ import { SUPPORTED_NODEJS_VERSION, } from './consts.js'; import { ensureMigrated, getBackend, getProxyPassword, getToken, setProxyPassword, setToken } from './credentials.js'; +import { AuthError } from './errors/AuthError.js'; import { deleteFile, ensureApifyDirectory, ensureFolderExistsSync, rimrafPromised } from './files.js'; import { inputFileRegExp, TEMP_INPUT_KEY_PREFIX } from './input-key.js'; import type { AuthJSON } from './types.js'; @@ -141,7 +142,10 @@ export async function getLoggedClientOrThrow() { if (!loggedClient) { process.exitCode = CommandExitCodes.MissingAuth; - throw new Error('You are not logged in with your Apify account. Call "apify login" to fix that.'); + throw new AuthError( + 'You are not logged in with your Apify account. Call "apify login" to fix that.', + CommandExitCodes.MissingAuth, + ); } return loggedClient; }