diff --git a/messages/list.md b/messages/list.md index fb4e5b4b..7378a289 100644 --- a/messages/list.md +++ b/messages/list.md @@ -58,7 +58,7 @@ Use one of the "org login" commands or "org create scratch" to add or create a s # noResultsFound -No non-scratch orgs found. +No Orgs found. # cleanWarning diff --git a/schemas/org-list.json b/schemas/org-list.json index 81c2dc68..41bf056e 100644 --- a/schemas/org-list.json +++ b/schemas/org-list.json @@ -9,16 +9,35 @@ "type": "array", "items": { "$ref": "#/definitions/ExtendedAuthFields" - } + }, + "deprecated": "preserved for backward json compatibility. Duplicates devHubs, sandboxes, regularOrgs, which should be preferred" }, "scratchOrgs": { "type": "array", "items": { "$ref": "#/definitions/FullyPopulatedScratchOrgFields" } + }, + "sandboxes": { + "type": "array", + "items": { + "$ref": "#/definitions/ExtendedAuthFields" + } + }, + "other": { + "type": "array", + "items": { + "$ref": "#/definitions/ExtendedAuthFields" + } + }, + "devHubs": { + "type": "array", + "items": { + "$ref": "#/definitions/ExtendedAuthFields" + } } }, - "required": ["nonScratchOrgs", "scratchOrgs"], + "required": ["nonScratchOrgs", "scratchOrgs", "sandboxes", "other", "devHubs"], "additionalProperties": false }, "ExtendedAuthFields": { diff --git a/src/commands/org/list.ts b/src/commands/org/list.ts index a82c42af..08fc8e99 100644 --- a/src/commands/org/list.ts +++ b/src/commands/org/list.ts @@ -8,6 +8,7 @@ import { Flags, loglevel, SfCommand } from '@salesforce/sf-plugins-core'; import { AuthInfo, ConfigAggregator, ConfigInfo, Connection, Org, SfError, Messages, Logger } from '@salesforce/core'; import { Interfaces } from '@oclif/core'; +import * as chalk from 'chalk'; import { OrgListUtil, identifyActiveOrgByStatus } from '../../shared/orgListUtil'; import { getStyledObject } from '../../shared/orgHighlighter'; import { ExtendedAuthFields, FullyPopulatedScratchOrgFields } from '../../shared/orgTypes'; @@ -15,10 +16,20 @@ import { ExtendedAuthFields, FullyPopulatedScratchOrgFields } from '../../shared Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/plugin-org', 'list'); +export const defaultOrgEmoji = '🍁'; +export const defaultHubEmoji = '🌳'; + export type OrgListResult = { + /** + * @deprecated + * preserved for backward json compatibility. Duplicates devHubs, sandboxes, regularOrgs, which should be preferred*/ nonScratchOrgs: ExtendedAuthFields[]; scratchOrgs: FullyPopulatedScratchOrgFields[]; + sandboxes: ExtendedAuthFields[]; + other: ExtendedAuthFields[]; + devHubs: ExtendedAuthFields[]; }; + export class OrgListCommand extends SfCommand { public static readonly summary = messages.getMessage('summary'); public static readonly examples = messages.getMessages('examples'); @@ -56,6 +67,9 @@ export class OrgListCommand extends SfCommand { this.flags = flags; const metaConfigs = await OrgListUtil.readLocallyValidatedMetaConfigsGroupedByOrgType(fileNames, flags); const groupedSortedOrgs = { + devHubs: metaConfigs.devHubs.map(decorateWithDefaultStatus).sort(comparator), + other: metaConfigs.other.map(decorateWithDefaultStatus).sort(comparator), + sandboxes: metaConfigs.sandboxes.map(decorateWithDefaultStatus).sort(comparator), nonScratchOrgs: metaConfigs.nonScratchOrgs.map(decorateWithDefaultStatus).sort(comparator), scratchOrgs: metaConfigs.scratchOrgs.map(decorateWithDefaultStatus).sort(comparator), expiredScratchOrgs: metaConfigs.scratchOrgs.filter((org) => !identifyActiveOrgByStatus(org)), @@ -70,15 +84,29 @@ export class OrgListCommand extends SfCommand { } const result = { + other: groupedSortedOrgs.other, + sandboxes: groupedSortedOrgs.sandboxes, nonScratchOrgs: groupedSortedOrgs.nonScratchOrgs, + devHubs: groupedSortedOrgs.devHubs, scratchOrgs: flags.all ? groupedSortedOrgs.scratchOrgs : groupedSortedOrgs.scratchOrgs.filter(identifyActiveOrgByStatus), }; - this.printOrgTable(result.nonScratchOrgs, flags['skip-connection-status']); + this.printOrgTable({ + devHubs: result.devHubs, + other: result.other, + sandboxes: result.sandboxes, + scratchOrgs: result.scratchOrgs, + skipconnectionstatus: flags['skip-connection-status'], + }); - this.printScratchOrgTable(result.scratchOrgs); + this.info( + ` +Legend: ${defaultHubEmoji}=Default DevHub, ${defaultOrgEmoji}=Default Org ${ + flags.all ? '' : ' Use --all to see expired and deleted scratch orgs' + }` + ); return result; } @@ -112,95 +140,76 @@ export class OrgListCommand extends SfCommand { ); } - protected printOrgTable(nonScratchOrgs: ExtendedAuthFields[], skipconnectionstatus: boolean): void { - if (!nonScratchOrgs.length) { - this.log(messages.getMessage('noResultsFound')); - } else { - const rows = nonScratchOrgs - .map((row) => getStyledObject(row)) - .map((org) => - Object.fromEntries( - Object.entries(org).filter(([key]) => - ['defaultMarker', 'alias', 'username', 'orgId', 'connectedStatus'].includes(key) - ) - ) - ); - - this.table( - rows, - { - defaultMarker: { - header: '', - get: (data): string => data.defaultMarker ?? '', - }, - alias: { - header: 'ALIAS', - get: (data): string => data.alias ?? '', - }, - username: { header: 'USERNAME' }, - orgId: { header: 'ORG ID' }, - ...(!skipconnectionstatus ? { connectedStatus: { header: 'CONNECTED STATUS' } } : {}), - }, - { - title: 'Non-scratch orgs', - } - ); + protected printOrgTable({ + devHubs, + scratchOrgs, + other, + sandboxes, + skipconnectionstatus, + }: { + devHubs: ExtendedAuthFields[]; + other: ExtendedAuthFields[]; + sandboxes: ExtendedAuthFields[]; + scratchOrgs: FullyPopulatedScratchOrgFields[]; + skipconnectionstatus: boolean; + }): void { + if (!devHubs.length && !other.length && !sandboxes.length) { + this.info(messages.getMessage('noResultsFound')); + return; } - } + const allOrgs: Array = [ + ...devHubs + .map(addType('DevHub')) + .map(colorEveryFieldButConnectedStatus(chalk.cyanBright)) + .map((row) => getStyledObject(row)) + .map(statusToEmoji), - private printScratchOrgTable(scratchOrgs: FullyPopulatedScratchOrgFields[]): void { - if (scratchOrgs.length === 0) { - this.log(messages.getMessage('noActiveScratchOrgs')); - } else { - // One or more rows are available. - // we only need a few of the props for our table. Oclif table doesn't like extra props non-string props. - const rows = scratchOrgs - .map(getStyledObject) - .map((org) => - Object.fromEntries( - Object.entries(org).filter(([key]) => - [ - 'defaultMarker', - 'alias', - 'username', - 'orgId', - 'status', - 'expirationDate', - 'devHubOrgId', - 'createdDate', - 'instanceUrl', - ].includes(key) - ) - ) - ); - this.table( - rows, - { - defaultMarker: { - header: '', - get: (data): string => data.defaultMarker ?? '', - }, - alias: { - header: 'ALIAS', - get: (data): string => data.alias ?? '', - }, - username: { header: 'USERNAME' }, - orgId: { header: 'ORG ID' }, - ...(this.flags.all || this.flags.verbose ? { status: { header: 'STATUS' } } : {}), - ...(this.flags.verbose - ? { - devHubOrgId: { header: 'DEV HUB' }, - createdDate: { header: 'CREATED DATE' }, - instanceUrl: { header: 'INSTANCE URL' }, - } - : {}), - expirationDate: { header: 'EXPIRATION DATE' }, + ...other + .map(colorEveryFieldButConnectedStatus(chalk.magentaBright)) + .map((row) => getStyledObject(row)) + .map(statusToEmoji), + + ...sandboxes + .map(addType('Sandbox')) + .map(colorEveryFieldButConnectedStatus(chalk.yellowBright)) + .map((row) => getStyledObject(row)) + .map(statusToEmoji), + + ...scratchOrgs + .map((row) => ({ ...row, type: 'Scratch' })) + .map(convertScratchOrgStatus) + .map((row) => getStyledObject(row)) + .map(statusToEmoji), + ]; + + this.table( + allOrgs.map((org) => Object.fromEntries(Object.entries(org).filter(fieldFilter))), + { + defaultMarker: { + header: '', }, - { - title: 'Scratch orgs', - } - ); - } + type: { + header: 'Type', + }, + alias: { + header: 'Alias', + }, + username: { header: 'Username' }, + orgId: { header: 'Org ID' }, + ...(!skipconnectionstatus ? { connectedStatus: { header: 'Status' } } : {}), + ...(this.flags.verbose + ? { + instanceUrl: { header: 'Instance URL' }, + devHubOrgId: { header: 'Dev Hub ID' }, + createdDate: { + header: 'Created', + get: (data): string => (data.createdDate as string)?.split('T')?.[0] ?? '', + }, + } + : {}), + expirationDate: { header: 'Expires' }, + } + ); } } @@ -208,12 +217,20 @@ const decorateWithDefaultStatus = (val: T): T => ({ + ...val, + defaultMarker: val.defaultMarker?.replace('(D)', defaultHubEmoji)?.replace('(U)', defaultOrgEmoji), +}); + +const EMPTIES_LAST = 'zzzzzzzzzz'; + // sort by alias then username const comparator = (a: T, b: T): number => { - const aliasCompareResult = (a.alias ?? '').localeCompare(b.alias ?? ''); - return aliasCompareResult !== 0 ? aliasCompareResult : (a.username ?? '').localeCompare(b.username); + const aliasCompareResult = (a.alias ?? EMPTIES_LAST).localeCompare(b.alias ?? EMPTIES_LAST); + return aliasCompareResult !== 0 ? aliasCompareResult : (a.username ?? EMPTIES_LAST).localeCompare(b.username); }; const getAuthFileNames = async (): Promise => { @@ -228,3 +245,41 @@ const getAuthFileNames = async (): Promise => { } } }; + +type ExtendedAuthFieldsWithType = ExtendedAuthFields & { type?: string }; + +const addType = + (type: string) => + (val: ExtendedAuthFields): ExtendedAuthFieldsWithType => ({ ...val, type }); + +const colorEveryFieldButConnectedStatus = + (colorFn: chalk.Chalk) => + (row: ExtendedAuthFieldsWithType): ExtendedAuthFieldsWithType => + Object.fromEntries( + Object.entries(row).map(([key, val]) => [ + key, + typeof val === 'string' && key !== 'connectedStatus' ? colorFn(val) : val, + ]) + // TS is not smart enough to know this didn't change any types + ) as ExtendedAuthFieldsWithType; + +const fieldFilter = ([key]: [string, string]): boolean => + [ + 'defaultMarker', + 'alias', + 'username', + 'orgId', + 'status', + 'connectedStatus', + 'expirationDate', + 'devHubOrgId', + 'createdDate', + 'instanceUrl', + 'type', + 'createdDate', + ].includes(key); + +const convertScratchOrgStatus = ( + row: FullyPopulatedScratchOrgFields +): FullyPopulatedScratchOrgFields & { connectedStatus: string } => + ({ ...row, connectedStatus: row.status } as FullyPopulatedScratchOrgFields & { connectedStatus: string }); diff --git a/src/shared/orgHighlighter.ts b/src/shared/orgHighlighter.ts index 01392650..62265a04 100644 --- a/src/shared/orgHighlighter.ts +++ b/src/shared/orgHighlighter.ts @@ -19,6 +19,7 @@ const styledProperties = new Map>([ 'connectedStatus', new Map([ ['Connected', chalk.green], + ['Active', chalk.green], ['else', chalk.red], ]), ], @@ -34,11 +35,10 @@ export const getStyledValue = (key: string, value: string): string => { return chalkMethod(value); }; -export const getStyledObject = ( - objectToStyle: ExtendedAuthFields | FullyPopulatedScratchOrgFields | Record -): Record => { - const clonedObject = { ...objectToStyle }; - return Object.fromEntries( - Object.entries(clonedObject).map(([key, value]) => [key, getStyledValue(key, value as string)]) - ); -}; +export const getStyledObject = (objectToStyle: T): T => + Object.fromEntries( + Object.entries(objectToStyle).map(([key, value]) => [ + key, + typeof value === 'string' ? getStyledValue(key, value) : value, + ]) + ) as T; diff --git a/src/shared/orgListUtil.ts b/src/shared/orgListUtil.ts index e979861b..16dd6258 100644 --- a/src/shared/orgListUtil.ts +++ b/src/shared/orgListUtil.ts @@ -38,6 +38,9 @@ type OrgGroups = { type OrgGroupsFullyPopulated = { nonScratchOrgs: ExtendedAuthFields[]; scratchOrgs: FullyPopulatedScratchOrgFields[]; + other: ExtendedAuthFields[]; + sandboxes: ExtendedAuthFields[]; + devHubs: ExtendedAuthFields[]; }; type ExtendedScratchOrgInfo = Record & @@ -93,6 +96,9 @@ export class OrgListUtil { return { nonScratchOrgs, scratchOrgs, + sandboxes: nonScratchOrgs.filter(sandboxFilter), + other: nonScratchOrgs.filter(regularOrgFilter), + devHubs: nonScratchOrgs.filter(devHubFilter), }; } @@ -367,3 +373,7 @@ const removeRestrictedInfoFromConfig = ( config: AuthFieldsFromFS, properties: string[] = ['refreshToken', 'clientSecret'] ): AuthFieldsFromFS => omit>(config, properties); + +const sandboxFilter = (org: AuthFieldsFromFS): boolean => Boolean(org.isSandbox); +const regularOrgFilter = (org: AuthFieldsFromFS): boolean => !org.isSandbox && !org.isDevHub; +const devHubFilter = (org: AuthFieldsFromFS): boolean => Boolean(org.isDevHub); diff --git a/test/nut/listAndDisplay.nut.ts b/test/nut/listAndDisplay.nut.ts index 78288df2..ffcbb7f2 100644 --- a/test/nut/listAndDisplay.nut.ts +++ b/test/nut/listAndDisplay.nut.ts @@ -11,7 +11,7 @@ import { expect, config, assert } from 'chai'; import { TestSession } from '@salesforce/cli-plugins-testkit'; import { execCmd } from '@salesforce/cli-plugins-testkit'; import { getString } from '@salesforce/ts-types'; -import { OrgListResult } from '../../src/commands/org/list'; +import { OrgListResult, defaultHubEmoji, defaultOrgEmoji } from '../../src/commands/org/list'; import { OrgOpenOutput } from '../../src/commands/org/open'; import { OrgDisplayReturn } from '../../src/shared/orgTypes'; @@ -27,11 +27,11 @@ const verifyHumanResults = ( expect(lines.length).to.have.greaterThan(0); const devHubLine = lines.find((line) => line.includes(hubOrgUsername)); assert(devHubLine); - expect(devHubLine).to.include('(D)'); + expect(devHubLine).to.include(defaultHubEmoji); expect(devHubLine).to.include('Connected'); const defaultUserLine = lines.find((line) => line.includes(defaultUsername)); assert(defaultUserLine); - expect(defaultUserLine).to.include('(U)'); + expect(defaultUserLine).to.include(defaultOrgEmoji); const aliasUserLine = lines.find((line) => line.includes(aliasedUsername)); assert(aliasUserLine); expect(aliasUserLine).to.include('anAlias'); @@ -100,8 +100,8 @@ describe('Org Command NUT', () => { expect(listResult.scratchOrgs).to.have.length(2); const scratchOrgs = listResult.scratchOrgs; expect(scratchOrgs.map((scratchOrg) => getString(scratchOrg, 'username'))).to.deep.equal([ - defaultUsername, aliasedUsername, + defaultUsername, ]); expect(scratchOrgs.find((org) => org.username === defaultUsername)).to.include({ defaultMarker: '(U)', @@ -121,6 +121,15 @@ describe('Org Command NUT', () => { }, JSON.stringify(listResult.nonScratchOrgs[0]) ); + expect(listResult.devHubs[0]).to.include( + { + username: hubOrgUsername, + defaultMarker: '(D)', + isDevHub: true, + connectedStatus: 'Connected', + }, + JSON.stringify(listResult.nonScratchOrgs[0]) + ); }); it('should list orgs - skip-connection-status', () => { const listResult = execCmd('org:list --skip-connection-status --json', { ensureExitCode: 0 }) diff --git a/test/shared/orgHighlighter.test.ts b/test/shared/orgHighlighter.test.ts index eb27a9a1..9096cb47 100644 --- a/test/shared/orgHighlighter.test.ts +++ b/test/shared/orgHighlighter.test.ts @@ -6,6 +6,7 @@ */ import { expect } from 'chai'; import * as chalk from 'chalk'; +import { ExtendedAuthFields } from '../../src/shared/orgTypes'; import { getStyledObject, getStyledValue } from '../../src/shared/orgHighlighter'; describe('highlights value from key-value pair', () => { @@ -26,7 +27,8 @@ describe('highlights object with green, red, and non-colored', () => { status: 'Active', otherProp: 'foo', connectedStatus: 'Not found', - }; + // I know it's not, but it's a test + } as unknown as ExtendedAuthFields; expect(getStyledObject(object)).to.deep.equal({ status: chalk.green('Active'), otherProp: 'foo', diff --git a/test/shared/orgListMock.ts b/test/shared/orgListMock.ts index 2e7a6605..7e551773 100644 --- a/test/shared/orgListMock.ts +++ b/test/shared/orgListMock.ts @@ -96,6 +96,15 @@ class OrgListMock { connectedStatus: 'Connected', }, ], + devHubs: [ + { + username: 'foo@example.com', + isDevHub: true, + connectedStatus: 'Connected', + }, + ], + sandboxes: [], + other: [], }; public static get devHubUsername(): string {