diff --git a/README.md b/README.md index acd3af94..a0374488 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ sfdx plugins - [`sf org list auth`](#sf-org-list-auth) - [`sf org login access-token`](#sf-org-login-access-token) +- [`sf org login client-credentials`](#sf-org-login-client-credentials) - [`sf org login jwt`](#sf-org-login-jwt) - [`sf org login sfdx-url`](#sf-org-login-sfdx-url) - [`sf org login web`](#sf-org-login-web) @@ -166,6 +167,75 @@ FLAG DESCRIPTIONS _See code: [src/commands/org/login/access-token.ts](https://github.com/salesforcecli/plugin-auth/blob/5.0.4/src/commands/org/login/access-token.ts)_ +## `sf org login client-credentials` + +Log in to a Salesforce org using the OAuth 2.0 client credentials flow. + +``` +USAGE + $ SF_CLIENT_SECRET=mySecretKey sf org login client-credentials -o -i [--json] [--flags-dir ] [-r ] [-d] [-s] [-a ] + +FLAGS + -a, --alias= Alias for the org. + -d, --set-default-dev-hub Set the authenticated org as the default Dev Hub. + -i, --client-id= (required) OAuth client ID (also called consumer key) of your custom connected app. + -o, --username= (required) Username of the user logging in. + -r, --instance-url= URL of the instance that the org lives on. + -s, --set-default Set the authenticated org as the default that all org-related commands run against. + +GLOBAL FLAGS + --flags-dir= Import flag values from a directory. + --json Format output as json. + +DESCRIPTION + Log in to a Salesforce org using the OAuth 2.0 client credentials flow. + + Use this command in automated environments where you can’t interactively log in with a browser, such as in CI/CD + scripts. + + Logging into an org authorizes the CLI to run other commands that connect to that org, such as deploying or retrieving + a project. You can log into many types of orgs, such as sandboxes, Dev Hubs, Env Hubs, production orgs, and scratch + orgs. + + Complete these steps before you run this command: + + 1. Create a connected app or external client app in your org. Enable the client credentials flow and choose the user + that the integration runs as. + 2. Make note of the consumer key (also called client id) and consumer secret (also called client secret) that are + generated for you. Set the consumer secret in the SF_CLIENT_SECRET environment variable. When you run this command, + set the --client-id flag to the consumer key. + 3. Use your org’s My Domain URL with --instance-url. The client credentials flow doesn’t support login.salesforce.com + or test.salesforce.com. + + See https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_client_credentials_flow.htm for more + information. + + We recommend that you set an alias when you log into an org. Aliases make it easy to later reference this org when + running commands that require it. If you don’t set an alias, the username for the user associated with the client credentials flow is used. + If you run multiple commands that reference the same org, consider setting the org as your default. Use + --set-default for your default scratch org or sandbox, or --set-default-dev-hub for your default Dev Hub. + +EXAMPLES + Set the org as the default and give it an alias: + + $ SF_CLIENT_SECRET=very-secret sf org login client-credentials --client-id \ + 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-org --set-default + + Set the org as the default Dev Hub and give it an alias: + + $ SF_CLIENT_SECRET=very-secret sf org login client-credentials --client-id \ + 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-dev-hub --set-default-dev-hub + +FLAG DESCRIPTIONS + -r, --instance-url= URL of the instance that the org lives on. + + To specify a production environment My Domain URL, use the format "https://.my.salesforce.com". + + To specify a sandbox, set --instance-url to "https://--.sandbox.my.salesforce.com". +``` + +_See code: [src/commands/org/login/client-credentials.ts](https://github.com/salesforcecli/plugin-auth/blob/5.0.4/src/commands/org/login/client-credentials.ts)_ + ## `sf org login jwt` Log in to a Salesforce org using a JSON web token (JWT). diff --git a/command-snapshot.json b/command-snapshot.json index 1d95bc20..aa2e1d26 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -31,6 +31,24 @@ ], "plugin": "@salesforce/plugin-auth" }, + { + "alias": [], + "command": "org:login:client-credentials", + "flagAliases": [], + "flagChars": ["a", "d", "i", "p", "r", "s"], + "flags": [ + "alias", + "client-id", + "flags-dir", + "instance-url", + "json", + "loglevel", + "no-prompt", + "set-default", + "set-default-dev-hub" + ], + "plugin": "@salesforce/plugin-auth" + }, { "alias": ["force:auth:jwt:grant", "auth:jwt:grant"], "command": "org:login:jwt", diff --git a/messages/client.credentials.md b/messages/client.credentials.md new file mode 100644 index 00000000..a9d93b47 --- /dev/null +++ b/messages/client.credentials.md @@ -0,0 +1,45 @@ +# summary + +Log in to a Salesforce org using the OAuth 2.0 client credentials flow. + +# description + +Use this command in automated environments where you can’t interactively log in with a browser, such as in CI/CD scripts. + +Logging into an org authorizes the CLI to run other commands that connect to that org, such as deploying or retrieving a project. You can log into many types of orgs, such as sandboxes, Dev Hubs, Env Hubs, production orgs, and scratch orgs. + +Complete these steps before you run this command: + + 1. Create a connected app or external client app in your org. Enable the client credentials flow and choose the user that the integration runs as. + 2. Make note of the consumer key (also called client id) and consumer secret (also called client secret) that are generated for you. Set the consumer secret in the SF_CLIENT_SECRET environment variable. When you run this command, set the --client-id flag to the consumer key so the secret isn't passed as a command argument. + 3. Use your org’s My Domain URL with --instance-url. The client credentials flow doesn’t support login.salesforce.com or test.salesforce.com. + +See https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_client_credentials_flow.htm for more information. + +We recommend that you set an alias when you log into an org. Aliases make it easy to later reference this org when running commands that require it. If you don’t set an alias, you use the username that you specified when you logged in to the org. If you run multiple commands that reference the same org, consider setting the org as your default. Use --set-default for your default scratch org or sandbox, or --set-default-dev-hub for your default Dev Hub. + +# examples + +- Set the org as the default and give it an alias: + + SF_CLIENT_SECRET=very-secret <%= config.bin %> <%= command.id %> --client-id 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-org --set-default + +- Set the org as the default Dev Hub and give it an alias: + + SF_CLIENT_SECRET=very-secret <%= config.bin %> <%= command.id %> --client-id 04580y4051234051 --instance-url https://MyDomainName.my.salesforce.com --alias ci-dev-hub --set-default-dev-hub + +# ClientCredentialsGrantError + +We encountered a client credentials error, which is likely not an issue with Salesforce CLI. Here’s the error: %s + +# httpsRequired + +The client credentials flow requires an HTTPS instance URL. Use your org’s My Domain URL, such as https://MyDomainName.my.salesforce.com. + +# clientSecretMissingResponse + +The client secret environment variable was not set, aborting login call. + +# invalidTokenResponse + +The authorization server returned an incomplete token response. diff --git a/package.json b/package.json index 6f603479..20359f17 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,9 @@ "description": "Authorize an org for use with Salesforce CLI.", "longDescription": "Use the auth commands to authorize a Salesforce org for use with the Salesforce CLI.", "subtopics": { + "client-credentials": { + "description": "authorize an org using OAuth client credentials" + }, "jwt": { "description": "authorize an org using JWT" }, diff --git a/schemas/org-login-client__credentials.json b/schemas/org-login-client__credentials.json new file mode 100644 index 00000000..1fa992c1 --- /dev/null +++ b/schemas/org-login-client__credentials.json @@ -0,0 +1,135 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/AuthFields", + "definitions": { + "AuthFields": { + "type": "object", + "properties": { + "clientApps": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "oauthFlow": { + "type": "string", + "const": "web" + } + }, + "required": ["clientId", "accessToken", "refreshToken", "oauthFlow"], + "additionalProperties": false + } + }, + "accessToken": { + "type": "string" + }, + "alias": { + "type": "string" + }, + "authCode": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "created": { + "type": "string" + }, + "createdOrgInstance": { + "type": "string" + }, + "devHubUsername": { + "type": "string" + }, + "instanceUrl": { + "type": "string" + }, + "instanceApiVersion": { + "type": "string" + }, + "instanceApiVersionLastRetrieved": { + "type": "string" + }, + "isDevHub": { + "type": "boolean" + }, + "loginUrl": { + "type": "string" + }, + "orgId": { + "type": "string" + }, + "password": { + "type": "string" + }, + "privateKey": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "snapshot": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "username": { + "type": "string" + }, + "usernames": { + "type": "array", + "items": { + "type": "string" + } + }, + "userProfileName": { + "type": "string" + }, + "expirationDate": { + "type": "string" + }, + "tracksSource": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "instanceName": { + "type": "string" + }, + "namespacePrefix": { + "type": ["string", "null"] + }, + "isSandbox": { + "type": "boolean" + }, + "isScratch": { + "type": "boolean" + }, + "trailExpirationDate": { + "type": ["string", "null"] + }, + "orgEdition": { + "type": "string" + } + }, + "additionalProperties": false, + "description": "Fields for authorization, org, and local information." + } + } +} diff --git a/src/commands/org/login/client-credentials.ts b/src/commands/org/login/client-credentials.ts new file mode 100644 index 00000000..1c700bcd --- /dev/null +++ b/src/commands/org/login/client-credentials.ts @@ -0,0 +1,194 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Flags, SfCommand, loglevel } from '@salesforce/sf-plugins-core'; +import { AuthFields, Messages, SfError } from '@salesforce/core'; +import { env } from '@salesforce/kit'; +import { Interfaces } from '@oclif/core'; +import common from '../../../common.js'; +import AccessToken from './access-token.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-auth', 'client.credentials'); +const commonMessages = Messages.loadMessages('@salesforce/plugin-auth', 'messages'); + +type ClientCredentialsTokenResponse = { + accessToken: string; + instanceUrl: string; +}; + +const PREFERRED_ENV_ACCESS_TOKEN_NAME = 'SF_ACCESS_TOKEN'; + +export default class LoginClientCredentials extends SfCommand { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + + public static readonly flags = { + 'client-id': Flags.string({ + char: 'i', + summary: commonMessages.getMessage('flags.client-id.summary'), + required: true, + }), + 'instance-url': Flags.url({ + char: 'r', + summary: commonMessages.getMessage('flags.instance-url.summary'), + description: commonMessages.getMessage('flags.instance-url.description'), + required: true, + }), + 'set-default-dev-hub': Flags.boolean({ + char: 'd', + summary: commonMessages.getMessage('flags.set-default-dev-hub.summary'), + }), + 'set-default': Flags.boolean({ + char: 's', + summary: commonMessages.getMessage('flags.set-default.summary'), + }), + alias: Flags.string({ + char: 'a', + summary: commonMessages.getMessage('flags.alias.summary'), + }), + 'no-prompt': Flags.boolean({ + char: 'p', + summary: commonMessages.getMessage('flags.no-prompt.summary'), + hidden: true, + }), + loglevel, + }; + private flags!: Interfaces.InferredFlags; + private priorEnvAccessToken?: string; + + private static getClientSecret(): string { + const clientSecret = env.getString('SF_CLIENT_SECRET'); + if (!clientSecret) { + throw new SfError(messages.getMessage('clientSecretMissingResponse')); + } + return clientSecret; + } + + public async run(): Promise { + const { flags } = await this.parse(LoginClientCredentials); + this.flags = flags; + + try { + // cache any previously-stored access token; we will temporarily override that value (if any) + // when authenticating here, and then restore the prior value (or wipe out the variable entirely) + // in the finally block + this.priorEnvAccessToken = env.getString(PREFERRED_ENV_ACCESS_TOKEN_NAME); + return await this.performClientCredentialsLogin(); + } catch (err) { + const msg = err instanceof Error ? `${err.name}::${err.message}` : typeof err === 'string' ? err : 'UNKNOWN'; + throw SfError.create({ + message: messages.getMessage('ClientCredentialsGrantError', [msg]), + name: 'ClientCredentialsGrantError', + ...(err instanceof Error ? { cause: err } : {}), + }); + } finally { + env.setString(PREFERRED_ENV_ACCESS_TOKEN_NAME, this.priorEnvAccessToken); + } + } + + private async performClientCredentialsLogin(): Promise { + const loginUrl = await common.resolveLoginUrl(this.flags['instance-url']?.href); + const tokenResponse = await this.requestClientCredentialsToken(loginUrl); + + env.setString(PREFERRED_ENV_ACCESS_TOKEN_NAME, tokenResponse.accessToken); + + const response = await new AccessToken(this.transformClientCredArgsIntoAccessArgs(), this.config).run(); + return response; + } + + /** + * Exchange the connected app client id and secret for an access token. + * The secret is sent in the POST body, never the query string. + */ + private async requestClientCredentialsToken(loginUrl: string): Promise { + const base = loginUrl.endsWith('/') ? loginUrl : `${loginUrl}/`; + const tokenUrl = new URL('services/oauth2/token', base); + if (tokenUrl.protocol !== 'https:') { + throw new SfError(messages.getMessage('httpsRequired'), 'ClientCredentialsAuthError'); + } + + const body = new URLSearchParams(); + body.set('grant_type', 'client_credentials'); + body.set('client_id', this.flags['client-id']); + body.set('client_secret', LoginClientCredentials.getClientSecret()); + + const response = await fetch(tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + const payload = await parseTokenResponse(response); + if (!response.ok) { + const detail = payload.errorDescription ?? payload.error ?? `HTTP ${response.status}`; + throw new SfError(detail, 'ClientCredentialsAuthError'); + } + if (!payload.accessToken || !payload.instanceUrl) { + throw new SfError(messages.getMessage('invalidTokenResponse'), 'ClientCredentialsAuthError'); + } + + return { + accessToken: payload.accessToken, + instanceUrl: payload.instanceUrl, + }; + } + + private transformClientCredArgsIntoAccessArgs(): string[] { + // the AccessToken command, and other commands in general + // don't play nice with extra args being passed to them, so we strip out + // the one extra flag prior to calling that command after verifying the other flags match + const clientCredentialFlagsWithoutClientId = new Set(Object.keys(AccessToken.flags)); + if ( + Object.keys(LoginClientCredentials.flags) + .filter((flagName) => flagName !== 'client-id') + .find((flagName) => !clientCredentialFlagsWithoutClientId.has(flagName)) + ) { + throw new SfError('Access token login flags and client credential flags have diverged'); + } + + const accessTokenArgs = this.argv.filter( + (arg, index, args) => + !['-i', '--client-id'].includes(arg) && + !arg.startsWith('-i=') && + !arg.startsWith('--client-id=') && + !['-i', '--client-id'].includes(args[index - 1] ?? '') + ); + return accessTokenArgs; + } +} + +type TokenResponseBody = { + accessToken?: string; + instanceUrl?: string; + error?: string; + errorDescription?: string; +}; + +const parseTokenResponse = async (response: Response): Promise => { + try { + const raw = (await response.json()) as Record; + return { + accessToken: typeof raw.access_token === 'string' ? raw.access_token : undefined, + instanceUrl: typeof raw.instance_url === 'string' ? raw.instance_url : undefined, + error: typeof raw.error === 'string' ? raw.error : undefined, + errorDescription: typeof raw.error_description === 'string' ? raw.error_description : undefined, + }; + } catch { + return {}; + } +}; diff --git a/test/commands/org/login/login.client-credentials.test.ts b/test/commands/org/login/login.client-credentials.test.ts new file mode 100644 index 00000000..c2f83d7c --- /dev/null +++ b/test/commands/org/login/login.client-credentials.test.ts @@ -0,0 +1,161 @@ +/* + * Copyright 2026, Salesforce, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AuthFields, SfError } from '@salesforce/core'; +import { TestContext } from '@salesforce/core/testSetup'; +import type { SinonStub } from 'sinon'; +import { expect } from 'chai'; +import LoginClientCredentials from '../../../../src/commands/org/login/client-credentials.js'; +import AccessToken from '../../../../src/commands/org/login/access-token.js'; + +type Options = { + tokenRequestFails?: boolean; +}; + +describe('org:login:client-credentials', () => { + const $$ = new TestContext(); + const clientSecret = 'very-secret'; + const instanceUrl = 'https://MyDomainName.my.salesforce.com'; + const authFields = { username: 'jdoe@example.org' } as AuthFields; + let fetchStub: { callCount: number; firstCall: { args: unknown[] } }; + let accessTokenRunStub: SinonStub; + let originalClientSecret: string | undefined; + let originalAccessToken: string | undefined; + + beforeEach(() => { + originalClientSecret = process.env.SF_CLIENT_SECRET; + originalAccessToken = process.env.SF_ACCESS_TOKEN; + process.env.SF_CLIENT_SECRET = clientSecret; + accessTokenRunStub = $$.SANDBOX.stub(AccessToken.prototype, 'run').resolves(authFields); + }); + + afterEach(() => { + if (originalClientSecret !== undefined) { + process.env.SF_CLIENT_SECRET = originalClientSecret; + } else { + delete process.env.SF_CLIENT_SECRET; + } + if (originalAccessToken !== undefined) { + process.env.SF_ACCESS_TOKEN = originalAccessToken; + } else { + delete process.env.SF_ACCESS_TOKEN; + } + }); + + const jsonResponse = (body: unknown, ok = true, status = 200): Response => + ({ + ok, + status, + json: () => Promise.resolve(body), + } as Response); + + const prepareStubs = (options: Options = {}): void => { + /* eslint-disable camelcase */ + fetchStub = $$.SANDBOX.stub(globalThis, 'fetch').resolves( + options.tokenRequestFails + ? jsonResponse({ error: 'invalid_client', error_description: 'client identifier invalid' }, false, 400) + : jsonResponse({ + access_token: '00Dxx0000000000!token', + instance_url: instanceUrl, + }) + ); + /* eslint-enable camelcase */ + }; + + it('should return the access-token login response', async () => { + prepareStubs(); + const response = await LoginClientCredentials.run(['-i', '123456', '-r', instanceUrl, '--json']); + + expect(response).to.equal(authFields); + expect(accessTokenRunStub.callCount).to.equal(1); + expect(process.env.SF_ACCESS_TOKEN).to.be.undefined; + }); + + it('should temporarily replace and then restore an existing access token', async () => { + const priorAccessToken = 'prior-access-token'; + process.env.SF_ACCESS_TOKEN = priorAccessToken; + accessTokenRunStub.callsFake(() => { + expect(process.env.SF_ACCESS_TOKEN).to.equal('00Dxx0000000000!token'); + return Promise.resolve(authFields); + }); + prepareStubs(); + + await LoginClientCredentials.run(['-i', '123456', '-r', instanceUrl, '--json']); + + expect(process.env.SF_ACCESS_TOKEN).to.equal(priorAccessToken); + }); + + it('should omit the short client-id flag before delegating to the access-token command', async () => { + prepareStubs(); + await LoginClientCredentials.run(['-i', '123456', '-r', instanceUrl, '--set-default', '--json']); + + const delegatedCommand = accessTokenRunStub.firstCall.thisValue as { argv: string[] }; + expect(delegatedCommand.argv).to.deep.equal(['-r', instanceUrl, '--set-default', '--json']); + }); + + it('should omit the long client-id flag before delegating to the access-token command', async () => { + prepareStubs(); + await LoginClientCredentials.run(['--client-id', '123456', '-r', instanceUrl, '--alias', 'ci-org', '--json']); + + const delegatedCommand = accessTokenRunStub.firstCall.thisValue as { argv: string[] }; + expect(delegatedCommand.argv).to.deep.equal(['-r', instanceUrl, '--alias', 'ci-org', '--json']); + }); + + it('should throw an error when the client secret environment variable is missing', async () => { + delete process.env.SF_CLIENT_SECRET; + try { + await LoginClientCredentials.run(['-i', '123456', '-r', instanceUrl, '--json']); + expect.fail('Should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(Error); + const authError = e as SfError; + expect(authError.message).to.include('The client secret environment variable was not set'); + } + expect(accessTokenRunStub.callCount).to.equal(0); + }); + + it('should request a token with client credentials in the POST body, not the URL', async () => { + prepareStubs(); + await LoginClientCredentials.run(['-i', '123456', '-r', instanceUrl, '--json']); + + expect(fetchStub.callCount).to.equal(1); + const [url, init] = fetchStub.firstCall.args as [URL, RequestInit]; + expect(url.pathname).to.equal('/services/oauth2/token'); + expect(url.search).to.equal(''); + expect(String(url)).to.not.include(clientSecret); + expect(init.method).to.equal('POST'); + expect(init.headers).to.deep.equal({ 'Content-Type': 'application/x-www-form-urlencoded' }); + const body = String(init.body); + expect(body).to.include('grant_type=client_credentials'); + expect(body).to.include('client_id=123456'); + expect(body).to.include(`client_secret=${clientSecret}`); + }); + + it('should wrap token request errors', async () => { + prepareStubs({ tokenRequestFails: true }); + try { + await LoginClientCredentials.run(['-i', '123456INVALID', '-r', instanceUrl, '--json']); + expect.fail('Should have thrown an error'); + } catch (e) { + expect(e).to.be.instanceOf(Error); + const authError = e as SfError; + expect(authError.message).to.include('We encountered a client credentials error'); + expect(authError.message).to.include('client identifier invalid'); + expect(authError.cause, 'ClientCredentialsGrantError should include original error as the cause').to.be.ok; + } + expect(accessTokenRunStub.callCount).to.equal(0); + }); +}); diff --git a/test/hooks/diagnostics.test.ts b/test/hooks/diagnostics.test.ts index adbe90e4..e59bb023 100644 --- a/test/hooks/diagnostics.test.ts +++ b/test/hooks/diagnostics.test.ts @@ -75,6 +75,7 @@ describe('Doctor diagnostics', () => { it('should fail when CLI does not support v2 crypto', async () => { sandbox.stub(util, 'promisify').returns(() => ({ stdout: JSON.stringify([{ version: '6.5.0' }]) })); + sandbox.stub(fs, 'readFileSync').throws(new Error('key file is unavailable')); process.env.SF_USE_GENERIC_UNIX_KEYCHAIN = 'false'; await hook({ doctor: doctorMock }); @@ -101,6 +102,7 @@ describe('Doctor diagnostics', () => { it('should pass when CLI supports v2 crypto', async () => { sandbox.stub(util, 'promisify').returns(() => ({ stdout: JSON.stringify([{ version: '6.7.0' }]) })); + sandbox.stub(fs, 'readFileSync').throws(new Error('key file is unavailable')); process.env.SF_USE_GENERIC_UNIX_KEYCHAIN = 'false'; await hook({ doctor: doctorMock });