UNPKG

@salesforce/plugin-org

Version:

Commands to interact with Salesforce orgs

322 lines 15.7 kB
/* * 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 { basename, join } from 'node:path'; import fs from 'node:fs/promises'; import { Org, AuthInfo, envVars, Global, Logger, Messages, SfError, trimTo15, ConfigAggregator, OrgConfigProperties, } from '@salesforce/core'; import { omit } from '@salesforce/kit'; import utils, { isDefined } from './utils.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const secretsMessages = Messages.loadMessages('@salesforce/plugin-org', 'secrets-redacted'); export class OrgListUtil { static logger; static async retrieveLogger() { if (!OrgListUtil.logger) { OrgListUtil.logger = await Logger.child('OrgListUtil'); } return OrgListUtil.logger; } /** * This method takes all locally configured orgs and organizes them into the following buckets: * { nonScratchOrgs: [{}], scratchOrgs: [{}] } * the scratchOrgInfo query. * * @param {string[]|null} userFilenames- an array of strings that are validated against the server. * @param {object} flags - the result of this.flags on an sfdx command */ static async readLocallyValidatedMetaConfigsGroupedByOrgType(userFilenames, skipConnection = false) { const contents = await OrgListUtil.readAuthFiles(userFilenames); const orgs = await OrgListUtil.groupOrgs(contents); // parallelize two very independent operations const [nonScratchOrgs, scratchOrgs] = await Promise.all([ Promise.all(orgs.nonScratchOrgs.map(async (fields) => { if (!skipConnection && fields.username) { // skip completely if we're skipping the connection fields.connectedStatus = await OrgListUtil.determineConnectedStatusForNonScratchOrg(fields.username); if (!fields.isDevHub && fields.connectedStatus === 'Connected') { // activating DevHub setting is irreversible so don't waste time checking any org we already know is a hub fields.isDevHub = await OrgListUtil.checkNonScratchOrgIsDevHub(fields.username); } } return fields; })), OrgListUtil.processScratchOrgs(orgs.scratchOrgs), ]); // TODO: Remove env var workaround const showSecretsEnvVarIsSet = envVars.getBoolean('SF_TEMP_SHOW_SECRETS', false); const redactSecrets = (org) => ({ ...org, accessToken: showSecretsEnvVarIsSet ? org.accessToken : secretsMessages.getMessage('redacted.accessToken'), password: org.password ? showSecretsEnvVarIsSet ? org.password : secretsMessages.getMessage('redacted.userPassword') : undefined, }); const redactedNonScratchOrgs = nonScratchOrgs.map(redactSecrets); const redactedScratchOrgs = scratchOrgs.map(redactSecrets); return { nonScratchOrgs: redactedNonScratchOrgs, scratchOrgs: redactedScratchOrgs, sandboxes: redactedNonScratchOrgs.filter(sandboxFilter), other: redactedNonScratchOrgs.filter(regularOrgFilter), devHubs: redactedNonScratchOrgs.filter(devHubFilter), }; } /** * Organizes the scratchOrgs by DevHub to optimize calls to retrieveScratchOrgInfoFromDevHub(), then calls reduceScratchOrgInfo() * * @param {ExtendedAuthFields[]} scratchOrgs- an array of strings that are validated against the server. * @returns the same scratch org list, but with updated information from the server. */ static async processScratchOrgs(scratchOrgs) { const orgIdsGroupedByDevHub = new Map(); scratchOrgs.forEach((fields) => { orgIdsGroupedByDevHub.set(fields.devHubUsername, (orgIdsGroupedByDevHub.get(fields.devHubUsername) ?? []).concat([trimTo15(fields.orgId)])); }); const updatedContents = (await Promise.all(Array.from(orgIdsGroupedByDevHub).map(([devHubUsername, orgIds]) => OrgListUtil.retrieveScratchOrgInfoFromDevHub(devHubUsername, orgIds)))).reduce((accumulator, iterator) => [...accumulator, ...iterator], []); return OrgListUtil.reduceScratchOrgInfo(updatedContents, scratchOrgs); } /** * Used to retrieve authInfo of the auth files * * @param fileNames All the filenames in the global hidden folder */ static async readAuthFiles(fileNames) { // Ensure that the Global.SFDX_DIR exists // https://github.com/forcedotcom/cli/issues/2222 await fs.mkdir(Global.SFDX_DIR, { recursive: true }); const orgFileNames = (await fs.readdir(Global.SFDX_DIR)).filter((filename) => filename.match(/^00D.{15}\.json$/g)); // Get default org configuration to always include it even if it's a secondary user const configAggregator = await ConfigAggregator.create(); const defaultOrg = configAggregator.getPropertyValue(OrgConfigProperties.TARGET_ORG); const allAuths = await Promise.all(fileNames.map(async (fileName) => { try { const orgUsername = basename(fileName, '.json'); const auth = await AuthInfo.create({ username: orgUsername }); const userId = auth?.getFields().userId; // no userid? Definitely an org primary user if (!userId) { return auth; } const orgId = auth.getFields().orgId; if (!orgId) { throw new SfError('No orgId found in auth file'); } const orgFileName = `${orgId}.json`; // if userId, it could be created from password:generate command. If <orgId>.json doesn't exist, it's also not a secondary user auth file if (orgId && !orgFileNames.includes(orgFileName)) { return auth; } // Theory: within <orgId>.json, if the userId is the first entry, that's the primary username. if (orgFileNames.includes(orgFileName)) { const orgFileContent = JSON.parse(await fs.readFile(join(Global.SFDX_DIR, orgFileName), 'utf8')); const usernames = orgFileContent.usernames; // Always include the default org, even if it's a secondary user if (defaultOrg === auth.getFields().username) { return auth; } // Otherwise, only include primary users (first in the usernames array) if (usernames && usernames[0] === auth.getFields().username) { return auth; } } } catch (error) { const err = error; const logger = await OrgListUtil.retrieveLogger(); logger.warn(`Problem reading file: ${fileName} skipping`); logger.warn(err.message); } })); return allAuths.filter(isDefined); } /** * Helper to group orgs by {scratchOrg, nonScratchOrgs} * Also identifies which are default orgs from config * * @param {object} contents -The authinfo retrieved from the auth files * @param {string[]} excludeProperties - properties to exclude from the grouped configs ex. ['refreshToken', 'clientSecret'] * @private */ static async groupOrgs(authInfos) { const configAggregator = await ConfigAggregator.create(); const results = await Promise.all(authInfos.map(async (authInfo) => { // for (const authInfo of authInfos) { let currentValue; try { // we're going to assert that these have a username/orgId because they came from the auth files currentValue = removeRestrictedInfoFromConfig(authInfo.getFields(true)); } catch (error) { const logger = await OrgListUtil.retrieveLogger(); logger.warn(`Error decrypting ${authInfo.getUsername()}`); currentValue = removeRestrictedInfoFromConfig(authInfo.getFields()); } const [alias, lastUsed] = await Promise.all([ utils.getAliasByUsername(currentValue.username), fs.stat(join(Global.SFDX_DIR, `${currentValue.username}.json`)), ]); return { ...identifyDefaultOrgs({ ...currentValue, alias }, configAggregator), lastUsed: lastUsed.atime, alias, }; })); return { scratchOrgs: results.filter((result) => 'expirationDate' in result), nonScratchOrgs: results.filter((result) => !('expirationDate' in result)), }; } static async retrieveScratchOrgInfoFromDevHub(devHubUsername, orgIdsToQuery) { const fields = [ 'CreatedDate', 'Edition', 'Status', 'ExpirationDate', 'Namespace', 'OrgName', 'CreatedBy.Username', 'SignupUsername', 'LoginUrl', 'ScratchOrg', ]; try { const devHubOrg = await Org.create({ aliasOrUsername: devHubUsername }); const conn = devHubOrg.getConnection(); const data = await conn .sobject('ScratchOrgInfo') .find({ ScratchOrg: { $in: orgIdsToQuery } }, fields); return data.map((org) => ({ ...org, devHubOrgId: devHubOrg.getOrgId(), })); } catch (err) { const logger = await OrgListUtil.retrieveLogger(); logger.warn(`Error querying ${devHubUsername} for ${orgIdsToQuery.length} orgIds`); return []; } } static async reduceScratchOrgInfo(updatedContents, orgs) { const contentMap = new Map(updatedContents.map((org) => [org.SignupUsername, org])); // Also create map by ScratchOrg (orgId) to handle cases where user authenticated as different user const contentMapByOrgId = new Map(updatedContents.map((org) => [org.ScratchOrg, org])); const results = orgs.map((scratchOrgInfo) => { // Try to match by username first, then by orgId const updatedOrgInfo = contentMap.get(scratchOrgInfo.username) ?? contentMapByOrgId.get(trimTo15(scratchOrgInfo.orgId)); return updatedOrgInfo ? { ...scratchOrgInfo, signupUsername: updatedOrgInfo.SignupUsername, createdBy: updatedOrgInfo.CreatedBy.Username, createdDate: updatedOrgInfo.CreatedDate, devHubOrgId: updatedOrgInfo.devHubOrgId, devHubId: updatedOrgInfo.devHubOrgId, attributes: updatedOrgInfo.attributes, orgName: updatedOrgInfo.OrgName, edition: updatedOrgInfo.Edition, status: updatedOrgInfo.Status, expirationDate: updatedOrgInfo.ExpirationDate, isExpired: updatedOrgInfo.Status === 'Deleted', namespace: updatedOrgInfo.Namespace, } : `Can't find ${scratchOrgInfo.username} in the updated contents`; }); const warnings = results.filter((result) => typeof result === 'string'); if (warnings.length) { const logger = await OrgListUtil.retrieveLogger(); warnings.forEach((warning) => logger.warn(warning)); } return results.filter((result) => typeof result !== 'string'); } /** * Asks the org if it's a devHub. Because the dev hub setting can't be deactivated, only ask orgs that aren't already stored as hubs. * This has a number of side effects, including updating the AuthInfo files and * * @param username org to check for devHub status * @returns {Promise.<boolean>} */ static async checkNonScratchOrgIsDevHub(username) { try { const org = await Org.create({ aliasOrUsername: username }); // true forces a server check instead of relying on AuthInfo file cache return await org.determineIfDevHubOrg(true); } catch { return false; } } /** * retrieves the connection info of an nonscratch org * * @param username The username used when the org was authenticated * @returns {Promise.<string>} */ static async determineConnectedStatusForNonScratchOrg(username) { try { const org = await Org.create({ aliasOrUsername: username }); if (org.getField(Org.Fields.DEV_HUB_USERNAME)) { return; } try { await org.refreshAuth(); return 'Connected'; } catch (err) { return authErrorHandler(err, org.getUsername()); } } catch (err) { return authErrorHandler(err, username); } } } export const identifyActiveOrgByStatus = (org) => 'status' in org && org.status === 'Active'; /** Identify the default orgs */ const identifyDefaultOrgs = (orgInfo, config) => { // remove undefined, since the config might also be undefined const possibleDefaults = [orgInfo.alias, orgInfo.username].filter(Boolean); return { ...orgInfo, isDefaultDevHubUsername: possibleDefaults.includes(config.getPropertyValue(OrgConfigProperties.TARGET_DEV_HUB)), isDefaultUsername: possibleDefaults.includes(config.getPropertyValue(OrgConfigProperties.TARGET_ORG)), }; }; /** * Helper utility to remove sensitive information from a scratch org auth config. By default refreshTokens and client secrets are removed. * * @param {*} config - scratch org auth object. * @param {string[]} properties - properties to exclude ex ['refreshToken', 'clientSecret'] * @returns the config less the sensitive information. */ const removeRestrictedInfoFromConfig = (config, properties = ['refreshToken', 'clientSecret']) => omit(config, properties); const sandboxFilter = (org) => Boolean(org.isSandbox); const regularOrgFilter = (org) => !org.isSandbox && !org.isDevHub; const devHubFilter = (org) => Boolean(org.isDevHub); const authErrorHandler = async (err, username) => { const error = err; const logger = await OrgListUtil.retrieveLogger(); logger.trace(`error refreshing auth for org: ${username}`); logger.trace(error); // Orgs under maintenance return html as the error message. if (error.message.includes('maintenance')) return 'Down (Maintenance)'; // handle other potential html responses if (error.message.includes('<html>') || error.message.includes('<!DOCTYPE HTML>')) return 'Bad Response'; return error.code ?? error.message; }; //# sourceMappingURL=orgListUtil.js.map