From c293e5057c97215e4d165dce57fb2428f2bf9968 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Tue, 23 Jul 2024 09:44:01 -0600 Subject: [PATCH 01/15] feat: use ink --- .eslintrc.cjs | 8 +- package.json | 12 +- src/commands/project/deploy/start.ts | 114 ++++- src/commands/project/retrieve/start.ts | 40 +- src/components/design-elements.ts | 20 + src/components/divider.tsx | 54 +++ src/components/spinner.tsx | 112 +++++ src/components/stage-tracker.ts | 81 ++++ src/components/stages.tsx | 548 +++++++++++++++++++++++++ src/components/timer.tsx | 46 +++ src/components/utils.ts | 40 ++ tsconfig.json | 5 +- yarn.lock | 535 +++++++++++++++++++++++- 13 files changed, 1574 insertions(+), 41 deletions(-) create mode 100644 src/components/design-elements.ts create mode 100644 src/components/divider.tsx create mode 100644 src/components/spinner.tsx create mode 100644 src/components/stage-tracker.ts create mode 100644 src/components/stages.tsx create mode 100644 src/components/timer.tsx create mode 100644 src/components/utils.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 115305c8..b76d6d2e 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -5,10 +5,16 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ module.exports = { - extends: ['eslint-config-salesforce-typescript', 'eslint-config-salesforce-license', 'plugin:sf-plugin/recommended'], + extends: [ + 'eslint-config-salesforce-typescript', + 'eslint-config-salesforce-license', + 'plugin:sf-plugin/recommended', + 'xo-react/space', + ], rules: { // allow deleting object properties via rest operator '@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }], + 'react/jsx-tag-spacing': 'off', }, ignorePatterns: ['test/nuts/specialTypes/*Project/**', 'test/nuts/retrieve/partialBundleDeleteProject/**'], }; diff --git a/package.json b/package.json index b41ef9ad..3e002224 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,12 @@ "@salesforce/source-deploy-retrieve": "^12.1.6", "@salesforce/source-tracking": "^7.0.9", "@salesforce/ts-types": "^2.0.10", - "ansis": "^3.2.1" + "ansis": "^3.2.1", + "change-case": "^5.4.4", + "cli-spinners": "^2", + "figures": "^6.1.0", + "ink": "^5.0.1", + "react": "^18.3.1" }, "devDependencies": { "@oclif/plugin-command-snapshot": "^5.2.5", @@ -24,7 +29,12 @@ "@salesforce/schemas": "^1.9.0", "@salesforce/source-testkit": "^2.2.38", "@salesforce/ts-sinon": "^1.4.20", + "@types/react": "^18.3.3", "cross-env": "^7.0.3", + "eslint-config-xo": "^0.45.0", + "eslint-config-xo-react": "^0.27.0", + "eslint-plugin-react": "^7.34.3", + "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-sf-plugin": "^1.18.11", "oclif": "^4.13.12", "ts-node": "^10.9.2", diff --git a/src/commands/project/deploy/start.ts b/src/commands/project/deploy/start.ts index 29f0998d..550737b9 100644 --- a/src/commands/project/deploy/start.ts +++ b/src/commands/project/deploy/start.ts @@ -5,15 +5,13 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import ansis from 'ansis'; import { EnvironmentVariable, Lifecycle, Messages, OrgConfigProperties, SfError } from '@salesforce/core'; -import { DeployVersionData } from '@salesforce/source-deploy-retrieve'; +import { DeployVersionData, MetadataApiDeployStatus } from '@salesforce/source-deploy-retrieve'; import { Duration } from '@salesforce/kit'; import { SfCommand, toHelpSection, Flags } from '@salesforce/sf-plugins-core'; -import { SourceConflictError } from '@salesforce/source-tracking'; +import { SourceConflictError, SourceMemberPollingEvent } from '@salesforce/source-tracking'; import { AsyncDeployResultFormatter } from '../../../formatters/asyncDeployResultFormatter.js'; import { DeployResultFormatter } from '../../../formatters/deployResultFormatter.js'; -import { DeployProgress } from '../../../utils/progressBar.js'; import { DeployResultJson, TestLevel } from '../../../utils/types.js'; import { executeDeploy, resolveApi, validateTests, determineExitCode } from '../../../utils/deploy.js'; import { DeployCache } from '../../../utils/deployCache.js'; @@ -22,9 +20,12 @@ import { ConfigVars } from '../../../configMeta.js'; import { coverageFormattersFlag, fileOrDirFlag, testLevelFlag, testsFlag } from '../../../utils/flags.js'; import { writeConflictTable } from '../../../utils/conflicts.js'; import { getOptionalProject } from '../../../utils/project.js'; +import { MultiStageComponent } from '../../../components/stages.js'; +import { round } from '../../../components/utils.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-deploy-retrieve', 'deploy.metadata'); +const mdTransferMessages = Messages.loadMessages('@salesforce/plugin-deploy-retrieve', 'metadata.transfer'); const exclusiveFlags = ['manifest', 'source-dir', 'metadata', 'metadata-dir']; const mdapiFormatFlags = 'Metadata API Format'; @@ -197,13 +198,76 @@ export default class DeployMetadata extends SfCommand { const api = await resolveApi(this.configAggregator); const username = flags['target-org'].getUsername(); - const action = flags['dry-run'] ? 'Deploying (dry-run)' : 'Deploying'; + const title = flags['dry-run'] ? 'Deploying Metadata (dry-run)' : 'Deploying Metadata'; + const ms = new MultiStageComponent<{ + mdapiDeploy: MetadataApiDeployStatus; + sourceMemberPolling: SourceMemberPollingEvent; + status: string; + apiData: DeployVersionData; + targetOrg: string; + }>({ + title, + stages: ['Preparing', 'Deploying Metadata', 'Running Tests', 'Updating Source Tracking', 'Done'], + jsonEnabled: this.jsonEnabled(), + info: [ + { + label: 'Status', + get: (data) => data?.mdapiDeploy && mdTransferMessages.getMessage(data?.mdapiDeploy?.status), + bold: true, + }, + + { + label: 'Deploy ID', + get: (data) => data?.mdapiDeploy?.id, + static: true, + }, + { + label: 'Target Org', + get: (data) => data?.targetOrg, + static: true, + }, + { + label: 'Components', + get: (data) => + data?.mdapiDeploy?.numberComponentsTotal + ? `${data?.mdapiDeploy?.numberComponentsDeployed}/${data?.mdapiDeploy?.numberComponentsTotal} (${round( + (data?.mdapiDeploy?.numberComponentsDeployed / data?.mdapiDeploy?.numberComponentsTotal) * 100, + 0 + )}%)` + : undefined, + stage: 'Deploying Metadata', + }, + { + label: 'Tests', + get: (data) => + data?.mdapiDeploy?.numberTestsTotal && data?.mdapiDeploy?.numberTestsCompleted + ? `${data?.mdapiDeploy?.numberTestsCompleted ?? 0}/${data?.mdapiDeploy?.numberTestsTotal ?? 0} ${ + data?.mdapiDeploy?.numberTestErrors ? `(Errors: ${data?.mdapiDeploy?.numberTestErrors})` : '' + }` + : undefined, + stage: 'Running Tests', + }, + { + label: 'Members', + get: (data) => + data?.sourceMemberPolling && + `${data.sourceMemberPolling.original - data.sourceMemberPolling.remaining}/${ + data.sourceMemberPolling.original + }`, + stage: 'Updating Source Tracking', + }, + ], + }); + + const lifecycle = Lifecycle.getInstance(); // eslint-disable-next-line @typescript-eslint/require-await - Lifecycle.getInstance().on('apiVersionDeploy', async (apiData: DeployVersionData) => { - this.log( + lifecycle.on('apiVersionDeploy', async (apiData: DeployVersionData) => { + ms.addMessage( messages.getMessage('apiVersionMsgDetailed', [ - action, + flags['dry-run'] ? 'Deploying (dry-run)' : 'Deploying', + // technically manifestVersion can be undefined, but only on raw mdapi deployments. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions flags['metadata-dir'] ? '' : `v${apiData.manifestVersion}`, username, apiData.apiVersion, @@ -222,6 +286,7 @@ export default class DeployMetadata extends SfCommand { ); if (!deploy) { + ms.stop(); this.log('No changes to deploy'); return { status: 'Nothing to deploy', files: [] }; } @@ -229,7 +294,7 @@ export default class DeployMetadata extends SfCommand { if (!deploy.id) { throw new SfError('The deploy id is not available.'); } - this.log(`Deploy ID: ${ansis.bold(deploy.id)}`); + // this.log(`Deploy ID: ${ansis.bold(deploy.id)}`); if (flags.async) { if (flags['coverage-formatters']) { @@ -240,7 +305,36 @@ export default class DeployMetadata extends SfCommand { return asyncFormatter.getJson(); } - new DeployProgress(deploy, this.jsonEnabled()).start(); + ms.goto('Preparing', { targetOrg: username }); + + // for sourceMember polling events + lifecycle.on('sourceMemberPollingEvent', (event: SourceMemberPollingEvent) => + Promise.resolve(ms.goto('Updating Source Tracking', { sourceMemberPolling: event })) + ); + + deploy.onUpdate((data) => { + if ( + data.numberComponentsDeployed === data.numberComponentsTotal && + data.numberTestsTotal > 0 && + data.numberComponentsDeployed > 0 + ) { + ms.goto('Running Tests', { mdapiDeploy: data }); + } else { + ms.goto('Deploying Metadata', { mdapiDeploy: data }); + } + }); + + deploy.onFinish((data) => { + ms.goto('Done', { mdapiDeploy: data.response, status: mdTransferMessages.getMessage(data.response.status) }); + ms.stop(); + }); + + deploy.onCancel(() => ms.stop()); + + deploy.onError((error: Error) => { + ms.stop(); + throw error; + }); const result = await deploy.pollStatus({ timeout: flags.wait }); process.exitCode = determineExitCode(result); diff --git a/src/commands/project/retrieve/start.ts b/src/commands/project/retrieve/start.ts index fd17e050..7e826086 100644 --- a/src/commands/project/retrieve/start.ts +++ b/src/commands/project/retrieve/start.ts @@ -27,6 +27,7 @@ import { SourceTracking, SourceConflictError } from '@salesforce/source-tracking import { Duration } from '@salesforce/kit'; import { Interfaces } from '@oclif/core'; +import { MultiStageComponent } from '../../../components/stages.js'; import { DEFAULT_ZIP_FILE_NAME, ensuredDirFlag, zipFileFlag } from '../../../utils/flags.js'; import { RetrieveResultFormatter } from '../../../formatters/retrieveResultFormatter.js'; import { MetadataRetrieveResultFormatter } from '../../../formatters/metadataRetrieveResultFormatter.js'; @@ -160,7 +161,26 @@ export default class RetrieveMetadata extends SfCommand { const format = flags['target-metadata-dir'] ? 'metadata' : 'source'; const zipFileName = flags['zip-file-name'] ?? DEFAULT_ZIP_FILE_NAME; - this.spinner.start(messages.getMessage('spinner.start')); + const stages = ['Preparing retrieve request', 'Sending request to org', 'Waiting for the org to respond', 'Done']; + const ms = new MultiStageComponent<{ + status: string; + apiVersion: string; + metadataApiVersion: string; + targetOrg: string; + }>({ + stages, + title: 'Retrieving Metadata', + jsonEnabled: this.jsonEnabled(), + info: [ + { + label: 'Status', + get: (data) => data?.status, + bold: true, + }, + ], + }); + + ms.goto(messages.getMessage('spinner.start'), { targetOrg: flags['target-org'].getUsername() }); const { componentSetFromNonDeletes, fileResponsesFromDelete = [] } = await buildRetrieveAndDeleteTargets( flags, @@ -178,14 +198,14 @@ export default class RetrieveMetadata extends SfCommand { } const retrieveOpts = await buildRetrieveOptions(flags, format, zipFileName, resolvedTargetDir); - this.spinner.status = messages.getMessage('spinner.sending'); + ms.goto(messages.getMessage('spinner.sending')); this.retrieveResult = new RetrieveResult({} as MetadataApiRetrieveStatus, componentSetFromNonDeletes); if (componentSetFromNonDeletes.size !== 0 || retrieveOpts.packageOptions?.length) { // eslint-disable-next-line @typescript-eslint/require-await Lifecycle.getInstance().on('apiVersionRetrieve', async (apiData: RetrieveVersionData) => { - this.log( + ms.addMessage( messages.getMessage('apiVersionMsgDetailed', [ 'Retrieving', `v${apiData.manifestVersion}`, @@ -195,23 +215,25 @@ export default class RetrieveMetadata extends SfCommand { ); }); const retrieve = await componentSetFromNonDeletes.retrieve(retrieveOpts); - this.spinner.status = messages.getMessage('spinner.polling'); + ms.goto(messages.getMessage('spinner.polling'), { status: 'Pending' }); retrieve.onUpdate((data) => { - this.spinner.status = mdTransferMessages.getMessage(data.status); + ms.goto(messages.getMessage('spinner.polling'), { status: mdTransferMessages.getMessage(data.status) }); }); // any thing else should stop the progress bar - retrieve.onFinish((data) => this.spinner.stop(mdTransferMessages.getMessage(data.response.status))); - retrieve.onCancel((data) => this.spinner.stop(mdTransferMessages.getMessage(data?.status ?? 'Canceled'))); + retrieve.onFinish((data) => ms.goto('Done', { status: mdTransferMessages.getMessage(data.response.status) })); + retrieve.onCancel((data) => + ms.goto('Done', { status: mdTransferMessages.getMessage(data?.status ?? 'Canceled') }) + ); retrieve.onError((error: Error) => { - this.spinner.stop(error.name); + ms.stop(error); throw error; }); this.retrieveResult = await retrieve.pollStatus(500, flags.wait.seconds); } - this.spinner.stop(); + ms.stop(); // flags['output-dir'] will set resolvedTargetDir var, so this check is redundant, but allows for nice typings in the moveResultsForRetrieveTargetDir method if (flags['output-dir'] && resolvedTargetDir) { diff --git a/src/components/design-elements.ts b/src/components/design-elements.ts new file mode 100644 index 00000000..265d4b04 --- /dev/null +++ b/src/components/design-elements.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import { type SpinnerName } from 'cli-spinners'; +import figures from 'figures'; + +export const icons = { + pending: figures.squareSmallFilled, + skipped: figures.circle, + completed: figures.tick, + failed: figures.cross, +}; + +export const spinners: Record = { + stage: process.platform === 'win32' ? 'line' : 'dots2', + info: process.platform === 'win32' ? 'line' : 'arc', +}; diff --git a/src/components/divider.tsx b/src/components/divider.tsx new file mode 100644 index 00000000..9a7b70c1 --- /dev/null +++ b/src/components/divider.tsx @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import { Box, Text } from 'ink'; +import React from 'react'; + +const getSideDividerWidth = (width: number, titleWidth: number): number => (width - titleWidth) / 2; +const getNumberOfCharsPerWidth = (char: string, width: number): number => width / char.length; + +const PAD = ' '; + +export function Divider({ + title = '', + width = 50, + padding = 1, + titlePadding = 1, + titleColor = 'white', + dividerChar = '─', + dividerColor = 'dim', +}: { + readonly title?: string; + readonly width?: number | 'full'; + readonly padding?: number; + readonly titleColor?: string; + readonly titlePadding?: number; + readonly dividerChar?: string; + readonly dividerColor?: string; +}): React.ReactNode { + const titleString = title ? `${PAD.repeat(titlePadding) + title + PAD.repeat(titlePadding)}` : ''; + const titleWidth = titleString.length; + const terminalWidth = process.stdout.columns ?? 80; + const widthToUse = width === 'full' ? terminalWidth - titlePadding : width > terminalWidth ? terminalWidth : width; + + const dividerWidth = getSideDividerWidth(widthToUse, titleWidth); + const numberOfCharsPerSide = getNumberOfCharsPerWidth(dividerChar, dividerWidth); + const dividerSideString = dividerChar.repeat(numberOfCharsPerSide); + + const paddingString = PAD.repeat(padding); + + return ( + + + {paddingString} + {dividerSideString} + {titleString} + {dividerSideString} + {paddingString} + + + ); +} diff --git a/src/components/spinner.tsx b/src/components/spinner.tsx new file mode 100644 index 00000000..44658148 --- /dev/null +++ b/src/components/spinner.tsx @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React, { useEffect, useState } from 'react'; +import spinners, { type SpinnerName } from 'cli-spinners'; +import { Box, Text } from 'ink'; +import { icons } from './design-elements.js'; + +export type UseSpinnerProps = { + /** + * Type of a spinner. + * See [cli-spinners](https://github.com/sindresorhus/cli-spinners) for available spinners. + * + * @default dots + */ + readonly type?: SpinnerName; +}; + +export type UseSpinnerResult = { + frame: string; +}; + +export function useSpinner({ type = 'dots' }: UseSpinnerProps): UseSpinnerResult { + const [frame, setFrame] = useState(0); + const spinner = spinners[type]; + + useEffect(() => { + const timer = setInterval(() => { + setFrame((previousFrame) => { + const isLastFrame = previousFrame === spinner.frames.length - 1; + return isLastFrame ? 0 : previousFrame + 1; + }); + }, spinner.interval); + + return (): void => { + clearInterval(timer); + }; + }, [spinner]); + + return { + frame: spinner.frames[frame] ?? '', + }; +} + +export type SpinnerProps = UseSpinnerProps & { + /** + * Label to show near the spinner. + */ + readonly label?: string; + readonly isBold?: boolean; + readonly labelPosition?: 'left' | 'right'; +}; + +export function Spinner({ isBold, label, type, labelPosition = 'right' }: SpinnerProps): React.ReactElement { + const { frame } = useSpinner({ type }); + + return ( + + {label && labelPosition === 'left' && {label}} + {isBold ? ( + + {frame} + + ) : ( + {frame} + )} + {label && labelPosition === 'right' && {label}} + + ); +} + +export function SpinnerOrError({ + error, + labelPosition = 'right', + ...props +}: SpinnerProps & { readonly error?: Error }): React.ReactElement { + if (error) { + return ( + + {props.label && labelPosition === 'left' && {props.label}} + {icons.failed} + {props.label && labelPosition === 'right' && {props.label}} + + ); + } + + return ; +} + +export function SpinnerOrErrorOrChildren({ + children, + error, + ...props +}: SpinnerProps & { + readonly children?: React.ReactNode; + readonly error?: Error; +}): React.ReactElement { + if (children) { + return ( + + {props.label && props.labelPosition === 'left' && {props.label}} + {children} + {props.label && props.labelPosition === 'right' && {props.label}} + + ); + } + + return ; +} diff --git a/src/components/stage-tracker.ts b/src/components/stage-tracker.ts new file mode 100644 index 00000000..09fa639a --- /dev/null +++ b/src/components/stage-tracker.ts @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import { Performance } from '@oclif/core/performance'; + +export type StageStatus = 'pending' | 'current' | 'completed' | 'skipped' | 'failed'; + +export class StageTracker extends Map { + public current: string | undefined; + private markers = new Map>(); + + public constructor(stages: readonly string[] | string[]) { + super(stages.map((stage) => [stage, 'pending'])); + } + + public set(stage: string, status: StageStatus): this { + if (status === 'current') { + this.current = stage; + } + return super.set(stage, status); + } + + public refresh(nextStage: string, opts?: { hasError?: boolean; isStopping?: boolean }): void { + const stages = [...this.keys()]; + for (const stage of stages) { + if (this.get(stage) === 'skipped') continue; + if (this.get(stage) === 'failed') continue; + + // .stop() was called with an error => set the stage to failed + if (nextStage === stage && opts?.hasError) { + this.set(stage, 'failed'); + this.stopMarker(stage); + continue; + } + + // .stop() was called without an error => set the stage to completed + if (nextStage === stage && opts?.isStopping) { + this.set(stage, 'completed'); + this.stopMarker(stage); + continue; + } + + // set the current stage + if (nextStage === stage) { + this.set(stage, 'current'); + // create a marker for the current stage if it doesn't exist + if (!this.markers.has(stage)) { + this.markers.set(stage, Performance.mark('MultiStageComponent', stage.replaceAll(' ', '-').toLowerCase())); + } + + continue; + } + + // any stage before the current stage should be marked as skipped if it's still pending + if (stages.indexOf(stage) < stages.indexOf(nextStage) && this.get(stage) === 'pending') { + this.set(stage, 'skipped'); + continue; + } + + // any stage before the current stage should be as completed (if it hasn't been marked as skipped or failed yet) + if (stages.indexOf(nextStage) > stages.indexOf(stage)) { + this.set(stage, 'completed'); + this.stopMarker(stage); + continue; + } + + // default to pending + this.set(stage, 'pending'); + } + } + + private stopMarker(stage: string): void { + const marker = this.markers.get(stage); + if (marker && !marker.stopped) { + marker.stop(); + } + } +} diff --git a/src/components/stages.tsx b/src/components/stages.tsx new file mode 100644 index 00000000..21371864 --- /dev/null +++ b/src/components/stages.tsx @@ -0,0 +1,548 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +import { env } from 'node:process'; +import { ux } from '@oclif/core/ux'; +import { capitalCase } from 'change-case'; +import { Box, Instance, render, Text } from 'ink'; +import React from 'react'; + +import { SpinnerOrError, SpinnerOrErrorOrChildren } from './spinner.js'; +import { icons, spinners } from './design-elements.js'; +import { StageTracker } from './stage-tracker.js'; +import { msInMostReadableFormat, secondsInMostReadableFormat } from './utils.js'; +import { Divider } from './divider.js'; +import { Timer } from './timer.js'; + +// Taken from https://github.com/sindresorhus/is-in-ci +const isInCi = + env.CI !== '0' && + env.CI !== 'false' && + ('CI' in env || 'CONTINUOUS_INTEGRATION' in env || Object.keys(env).some((key) => key.startsWith('CI_'))); + +type Info> = { + /** + * Color of the value. + */ + color?: string; + /** + * Get the value to display. Takes the data property on the MultiStageComponent as an argument. + * Useful if you want to apply some logic (like rendering a link) to the data before displaying it. + * + * @param data The data property on the MultiStageComponent. + * @returns {string | undefined} + */ + get?: (data?: T) => string | undefined; + /** + * Whether the value should be bold. + */ + bold?: boolean; + /** + * Whether the value should be a static key-value pair (not a spinner component). + */ + static?: boolean; + /** + * Label to display next to the value. + */ + label: string; + /** + * Stage to display the value on. If not provided, the value will be displayed at the bottom of the stages component. + */ + stage?: string; +}; + +type FormattedInfo = { + readonly color?: string; + readonly isBold?: boolean; + readonly isStatic?: boolean; + readonly label: string; + readonly value: string | undefined; + // eslint-disable-next-line react/no-unused-prop-types + readonly stage?: string; +}; + +type MultiStageComponentOptions> = { + /** + * Stages to render. + */ + readonly stages: readonly string[] | string[]; + /** + * Title to display at the top of the stages component. + */ + readonly title: string; + /** + * Information to display at the bottom of the stages component. + */ + readonly info?: Array>; + /** + * Messages to display at the bottom of the stages component. + * + * This will be rendered between the stages and the info section. + */ + readonly messages?: string[]; + /** + * Whether to show the total elapsed time. Defaults to true + */ + readonly showElapsedTime?: boolean; + /** + * Whether to show the time spent on each stage. Defaults to true + */ + readonly showStageTime?: boolean; + /** + * The unit to use for the timer. Defaults to 'ms' + */ + readonly timerUnit?: 'ms' | 's'; + /** + * Data to display in the stages component. This data will be passed to the get function in the info object. + */ + readonly data?: Partial; + /** + * Whether JSON output is enabled. Defaults to false. + * + * Pass in this.jsonEnabled() from the command class to determine if JSON output is enabled. + */ + readonly jsonEnabled: boolean; +}; + +type StagesProps = { + readonly error?: Error | undefined; + readonly info?: FormattedInfo[]; + readonly messages?: string[]; + readonly title: string; + readonly hasElapsedTime?: boolean; + readonly hasStageTime?: boolean; + readonly timerUnit?: 'ms' | 's'; + readonly stageTracker: StageTracker; +}; + +function StaticKeyValue({ label, value, isBold, color, isStatic }: FormattedInfo): React.ReactNode { + if (!value || !isStatic) return; + return ( + + {label}: + {value} + + ); +} + +function Infos({ info, error, stage }: { info: FormattedInfo[]; error?: Error; stage?: string }): React.ReactNode { + return ( + info + // If stage is provided, only show info for that stage + // otherwise, show all infos that don't have a specified stage + .filter((i) => (stage ? i.stage === stage : !i.stage)) + .map((i) => + i.isStatic ? ( + + ) : ( + + {i.value && ( + + {i.value} + + )} + + ) + ) + ); +} + +function Stages({ + error, + hasElapsedTime = true, + hasStageTime = true, + info, + messages, + stageTracker, + timerUnit = 'ms', + title, +}: StagesProps): React.ReactNode { + return ( + + + {messages && messages.length > 0 && ( + + {messages?.map((message) => ( + {message} + ))} + + )} + + + {[...stageTracker.entries()].map(([stage, status]) => ( + + + {(status === 'current' || status === 'failed') && ( + + )} + + {status === 'skipped' && ( + + {icons.skipped} {capitalCase(stage)} - Skipped + + )} + + {status === 'completed' && ( + + {icons.completed} + {capitalCase(stage)} + + )} + + {status === 'pending' && ( + + {icons.pending} {capitalCase(stage)} + + )} + {status !== 'pending' && status !== 'skipped' && hasStageTime && ( + + + + + )} + + + {info && status !== 'pending' && status !== 'skipped' && ( + + + + )} + + ))} + + + {info && ( + + + + )} + + {hasElapsedTime && ( + + Elapsed Time: + + + )} + + ); +} + +class CIMultiStageComponent> { + private seenStages: Set; + private data?: Partial; + private startTime: number | undefined; + private startTimes: Map = new Map(); + + private readonly info?: Array>; + private readonly stages: readonly string[] | string[]; + private readonly title: string; + private readonly hasElapsedTime?: boolean; + private readonly hasStageTime?: boolean; + private readonly timerUnit?: 'ms' | 's'; + + public constructor({ + data, + info, + showElapsedTime, + showStageTime, + stages, + timerUnit, + title, + }: MultiStageComponentOptions) { + this.title = title; + this.stages = stages; + this.seenStages = new Set(); + this.info = info; + this.hasElapsedTime = showElapsedTime ?? true; + this.hasStageTime = showStageTime ?? true; + this.timerUnit = timerUnit ?? 'ms'; + this.data = data; + + ux.stdout(`───── ${this.title} ─────`); + ux.stdout('Steps:'); + for (const stage of this.stages) { + ux.stdout(`${this.stages.indexOf(stage) + 1}. ${capitalCase(stage)}`); + } + ux.stdout(); + + if (this.hasElapsedTime) { + this.startTime = Date.now(); + } + } + + public update(stageTracker: StageTracker, data?: Partial): void { + this.data = { ...this.data, ...data } as T; + + for (const [stage, status] of stageTracker.entries()) { + // no need to re-render completed, failed, or skipped stages + if (this.seenStages.has(stage)) continue; + + switch (status) { + case 'pending': + // do nothing + break; + case 'current': + this.startTimes.set(stage, Date.now()); + break; + case 'failed': + case 'skipped': + case 'completed': + this.seenStages.add(stage); + if (this.hasStageTime && status !== 'skipped') { + const startTime = this.startTimes.get(stage); + const elapsedTime = startTime ? Date.now() - startTime : 0; + const displayTime = + this.timerUnit === 'ms' + ? msInMostReadableFormat(elapsedTime) + : secondsInMostReadableFormat(elapsedTime, 0); + ux.stdout(`${icons[status]} ${capitalCase(stage)} (${displayTime})`); + } else if (status === 'skipped') { + ux.stdout(`${icons[status]} ${capitalCase(stage)} - Skipped`); + } else { + ux.stdout(`${icons[status]} ${capitalCase(stage)}`); + } + + break; + default: + // do nothing + } + } + + this.printInfo(); + } + + // eslint-disable-next-line class-methods-use-this + public addMessage(message: string): void { + ux.stdout(message); + } + + public stop(stageTracker: StageTracker): void { + this.update(stageTracker); + if (this.startTime) { + const elapsedTime = Date.now() - this.startTime; + ux.stdout(); + const displayTime = + this.timerUnit === 'ms' ? msInMostReadableFormat(elapsedTime) : secondsInMostReadableFormat(elapsedTime, 0); + ux.stdout(`Elapsed time: ${displayTime}`); + } + + this.printInfo(); + } + + private printInfo(): void { + if (!this.info) return; + ux.stdout(); + for (const info of this.info) { + const formattedData = info.get ? info.get(this.data as T) : undefined; + if (formattedData) { + ux.stdout(`${info.label}: ${formattedData}`); + } + } + } +} + +export class MultiStageComponent> implements Disposable { + private data?: Partial; + private inkInstance: Instance | undefined; + private ciInstance: CIMultiStageComponent | undefined; + private stageTracker: StageTracker; + private stopped = false; + private messages: string[]; + + private readonly info?: Array>; + private readonly stages: readonly string[] | string[]; + private readonly title: string; + private readonly hasElapsedTime?: boolean; + private readonly hasStageTime?: boolean; + private readonly timerUnit?: 'ms' | 's'; + + public constructor({ + data, + info, + jsonEnabled, + messages, + showElapsedTime, + showStageTime, + stages, + timerUnit, + title, + }: MultiStageComponentOptions) { + this.data = data; + this.stages = stages; + this.title = title; + this.info = info; + this.hasElapsedTime = showElapsedTime ?? true; + this.hasStageTime = showStageTime ?? true; + this.timerUnit = timerUnit ?? 'ms'; + this.stageTracker = new StageTracker(stages); + this.messages = messages ?? []; + + if (!jsonEnabled) { + if (isInCi) { + this.ciInstance = new CIMultiStageComponent({ + stages, + title, + info, + showElapsedTime, + showStageTime, + timerUnit, + data, + jsonEnabled, + }); + } else { + this.inkInstance = render( + + ); + } + } + } + + public addMessage(message: string): void { + if (this.stopped) return; + this.messages.push(message); + if (isInCi) { + this.ciInstance?.addMessage(message); + } else { + this.inkInstance?.rerender( + + ); + } + } + + public next(data?: Partial): void { + if (this.stopped) return; + + const nextStageIndex = this.stages.indexOf(this.stageTracker.current ?? this.stages[0]) + 1; + if (nextStageIndex < this.stages.length) { + this.update(this.stages[nextStageIndex], data); + } + } + + public goto(stage: string, data?: Partial): void { + if (this.stopped) return; + + // ignore non-existent stages + if (!this.stages.includes(stage)) return; + + // prevent going to a previous stage + if (this.stages.indexOf(stage) < this.stages.indexOf(this.stageTracker.current ?? this.stages[0])) return; + + this.update(stage, data); + } + + public updateData(data: Partial): void { + if (this.stopped) return; + this.data = { ...this.data, ...data } as T; + + this.update(this.stageTracker.current ?? this.stages[0], data); + } + + public stop(error?: Error): void { + if (this.stopped) return; + this.stopped = true; + + this.stageTracker.refresh(this.stageTracker.current ?? this.stages[0], { hasError: !!error, isStopping: true }); + + if (isInCi) { + this.ciInstance?.stop(this.stageTracker); + return; + } + + if (error) { + this.inkInstance?.rerender( + + ); + } else { + this.inkInstance?.rerender( + + ); + } + + this.inkInstance?.unmount(); + } + + public [Symbol.dispose](): void { + this.inkInstance?.unmount(); + } + + private update(stage: string, data?: Partial): void { + this.data = { ...this.data, ...data } as Partial; + + this.stageTracker.refresh(stage); + + if (isInCi) { + this.ciInstance?.update(this.stageTracker, this.data); + } else { + this.inkInstance?.rerender( + + ); + } + } + + private formatInfo(): FormattedInfo[] { + return ( + this.info?.map((info) => { + const formattedData = info.get ? info.get(this.data as T) : undefined; + return { + value: formattedData, + label: info.label, + isBold: info.bold, + color: info.color, + isStatic: info.static, + stage: info.stage, + }; + }) ?? [] + ); + } +} diff --git a/src/components/timer.tsx b/src/components/timer.tsx new file mode 100644 index 00000000..64225113 --- /dev/null +++ b/src/components/timer.tsx @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import { Text } from 'ink'; +import React from 'react'; +import { msInMostReadableFormat, secondsInMostReadableFormat } from './utils.js'; + +export function Timer({ + color, + isStopped, + unit, +}: { + readonly color?: string; + readonly isStopped?: boolean; + readonly unit: 'ms' | 's'; +}): React.ReactNode { + const [time, setTime] = React.useState(0); + const [previousDate, setPreviousDate] = React.useState(Date.now()); + + React.useEffect(() => { + if (isStopped) { + setTime(time + (Date.now() - previousDate)); + setPreviousDate(Date.now()); + return () => {}; + } + + const intervalId = setInterval( + () => { + setTime(time + (Date.now() - previousDate)); + setPreviousDate(Date.now()); + }, + unit === 'ms' ? 1 : 1000 + ); + + return (): void => { + clearInterval(intervalId); + }; + }, [time, isStopped, previousDate, unit]); + + return ( + {unit === 'ms' ? msInMostReadableFormat(time) : secondsInMostReadableFormat(time, 0)} + ); +} diff --git a/src/components/utils.ts b/src/components/utils.ts new file mode 100644 index 00000000..c8b4c7a5 --- /dev/null +++ b/src/components/utils.ts @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ + +export function round(value: number, decimals = 2): string { + const factor = Math.pow(10, decimals); + return (Math.round(value * factor) / factor).toFixed(decimals); +} + +export function msInMostReadableFormat(time: number, decimals = 2): string { + // if time < 1000ms, return time in ms + if (time < 1000) { + return `${time}ms`; + } + + return secondsInMostReadableFormat(time, decimals); +} + +export function secondsInMostReadableFormat(time: number, decimals = 2): string { + if (time < 1000) { + return '< 1s'; + } + + // if time < 60s, return time in seconds + if (time < 60_000) { + return `${round(time / 1000, decimals)}s`; + } + + // if time < 60m, return time in minutes and seconds + if (time < 3_600_000) { + const minutes = Math.floor(time / 60_000); + const seconds = round((time % 60_000) / 1000, 0); + return `${minutes}m ${seconds}s`; + } + + return time.toString(); +} diff --git a/tsconfig.json b/tsconfig.json index 1fa9d631..b28bde85 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,8 @@ "outDir": "lib", "rootDir": "src", "skipLibCheck": true, - "baseUrl": "." + "baseUrl": ".", + "jsx": "react" }, - "include": ["./src/**/*.ts"] + "include": ["./src/**/*.ts", "./src/**/*.tsx"] } diff --git a/yarn.lock b/yarn.lock index a1df9e1d..28ae9e4f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,14 @@ # yarn lockfile v1 +"@alcalzone/ansi-tokenize@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz#9f89839561325a8e9a0c32360b8d17e48489993f" + integrity sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw== + dependencies: + ansi-styles "^6.2.1" + is-fullwidth-code-point "^4.0.0" + "@ampproject/remapping@^2.2.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" @@ -2708,6 +2716,19 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA== +"@types/prop-types@*": + version "15.7.12" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6" + integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q== + +"@types/react@^18.3.3": + version "18.3.3" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.3.tgz#9679020895318b0915d7a3ab004d92d33375c45f" + integrity sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw== + dependencies: + "@types/prop-types" "*" + csstype "^3.0.2" + "@types/responselike@^1.0.0": version "1.0.3" resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.3.tgz#cc29706f0a397cfe6df89debfe4bf5cea159db50" @@ -2927,6 +2948,13 @@ ansi-escapes@^5.0.0: dependencies: type-fest "^1.0.2" +ansi-escapes@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-7.0.0.tgz#00fc19f491bbb18e1d481b97868204f92109bfe7" + integrity sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw== + dependencies: + environment "^1.0.0" + ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" @@ -2956,7 +2984,7 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" -ansi-styles@^6.1.0, ansi-styles@^6.2.1: +ansi-styles@^6.0.0, ansi-styles@^6.1.0, ansi-styles@^6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== @@ -3031,7 +3059,7 @@ array-ify@^1.0.0: resolved "https://registry.yarnpkg.com/array-ify/-/array-ify-1.0.0.tgz#9e528762b4a9066ad163a6962a364418e9626ece" integrity sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng== -array-includes@^3.1.7: +array-includes@^3.1.6, array-includes@^3.1.7, array-includes@^3.1.8: version "3.1.8" resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.8.tgz#5e370cbe172fdd5dd6530c1d4aadda25281ba97d" integrity sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ== @@ -3048,6 +3076,18 @@ array-union@^2.1.0: resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== +array.prototype.findlast@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz#3e4fbcb30a15a7f5bf64cf2faae22d139c2e4904" + integrity sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + es-shim-unscopables "^1.0.2" + array.prototype.findlastindex@^1.2.3: version "1.2.5" resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz#8c35a755c72908719453f87145ca011e39334d0d" @@ -3060,7 +3100,7 @@ array.prototype.findlastindex@^1.2.3: es-object-atoms "^1.0.0" es-shim-unscopables "^1.0.2" -array.prototype.flat@^1.3.2: +array.prototype.flat@^1.3.1, array.prototype.flat@^1.3.2: version "1.3.2" resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz#1476217df8cff17d72ee8f3ba06738db5b387d18" integrity sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA== @@ -3080,6 +3120,27 @@ array.prototype.flatmap@^1.3.2: es-abstract "^1.22.1" es-shim-unscopables "^1.0.0" +array.prototype.toreversed@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/array.prototype.toreversed/-/array.prototype.toreversed-1.1.2.tgz#b989a6bf35c4c5051e1dc0325151bf8088954eba" + integrity sha512-wwDCoT4Ck4Cz7sLtgUmzR5UV3YF5mFHUlbChCzZBQZ+0m2cl/DH3tKgvphv1nKgFsJ48oCSg6p91q2Vm0I/ZMA== + dependencies: + call-bind "^1.0.2" + define-properties "^1.2.0" + es-abstract "^1.22.1" + es-shim-unscopables "^1.0.0" + +array.prototype.tosorted@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz#fe954678ff53034e717ea3352a03f0b0b86f7ffc" + integrity sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.3" + es-errors "^1.3.0" + es-shim-unscopables "^1.0.2" + arraybuffer.prototype.slice@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz#097972f4255e41bc3425e37dc3f6421cf9aefde6" @@ -3143,6 +3204,11 @@ atomic-sleep@^1.0.0: resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== +auto-bind@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/auto-bind/-/auto-bind-5.0.1.tgz#50d8e63ea5a1dddcb5e5e36451c1a8266ffbb2ae" + integrity sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg== + available-typed-arrays@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" @@ -3427,6 +3493,11 @@ change-case@^4, change-case@^4.1.2: snake-case "^3.0.4" tslib "^2.0.3" +change-case@^5.4.4: + version "5.4.4" + resolved "https://registry.yarnpkg.com/change-case/-/change-case-5.4.4.tgz#0d52b507d8fb8f204343432381d1a6d7bff97a02" + integrity sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w== + check-error@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.3.tgz#a6502e4312a7ee969f646e83bb3ddd56281bd694" @@ -3498,6 +3569,18 @@ clean-stack@^3.0.1: dependencies: escape-string-regexp "4.0.0" +cli-boxes@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-3.0.0.tgz#71a10c716feeba005e4504f36329ef0b17cf3145" + integrity sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g== + +cli-cursor@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-4.0.0.tgz#3cecfe3734bf4fe02a8361cbdc0f6fe28c6a57ea" + integrity sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg== + dependencies: + restore-cursor "^4.0.0" + cli-progress@^3.12.0: version "3.12.0" resolved "https://registry.yarnpkg.com/cli-progress/-/cli-progress-3.12.0.tgz#807ee14b66bcc086258e444ad0f19e7d42577942" @@ -3505,7 +3588,7 @@ cli-progress@^3.12.0: dependencies: string-width "^4.2.3" -cli-spinners@^2.9.2: +cli-spinners@^2, cli-spinners@^2.9.2: version "2.9.2" resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== @@ -3519,6 +3602,14 @@ cli-table3@^0.6.0: optionalDependencies: "@colors/colors" "1.5.0" +cli-truncate@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-4.0.0.tgz#6cc28a2924fee9e25ce91e973db56c7066e6172a" + integrity sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA== + dependencies: + slice-ansi "^5.0.0" + string-width "^7.0.0" + cli-width@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-4.1.0.tgz#42daac41d3c254ef38ad8ac037672130173691c5" @@ -3558,6 +3649,13 @@ clone-response@^1.0.2: dependencies: mimic-response "^1.0.0" +code-excerpt@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/code-excerpt/-/code-excerpt-4.0.0.tgz#2de7d46e98514385cb01f7b3b741320115f4c95e" + integrity sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA== + dependencies: + convert-to-spaces "^2.0.1" + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -3622,6 +3720,11 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +confusing-browser-globals@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz#ae40e9b57cdd3915408a2805ebd3a5585608dc81" + integrity sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA== + constant-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/constant-case/-/constant-case-3.0.4.tgz#3b84a9aeaf4cf31ec45e6bf5de91bdfb0589faf1" @@ -3670,6 +3773,11 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== +convert-to-spaces@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz#61a6c98f8aa626c16b296b862a91412a33bceb6b" + integrity sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ== + core-js-compat@^3.34.0: version "3.37.0" resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.37.0.tgz#d9570e544163779bb4dff1031c7972f44918dc73" @@ -3730,6 +3838,11 @@ csprng@*: dependencies: sequin "*" +csstype@^3.0.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + csv-parse@^5.5.2: version "5.5.6" resolved "https://registry.yarnpkg.com/csv-parse/-/csv-parse-5.5.6.tgz#0d726d58a60416361358eec291a9f93abe0b6b1a" @@ -3879,7 +3992,7 @@ define-lazy-prop@^3.0.0: resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz#dbb19adfb746d7fc6d734a06b72f4a00d021255f" integrity sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg== -define-properties@^1.2.0, define-properties@^1.2.1: +define-properties@^1.1.3, define-properties@^1.2.0, define-properties@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== @@ -4054,6 +4167,11 @@ entities@^4.2.0, entities@^4.5.0: resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== +environment@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/environment/-/environment-1.1.0.tgz#8e86c66b180f363c7ab311787e0259665f45a9f1" + integrity sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -4061,7 +4179,7 @@ error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.2: +es-abstract@^1.17.5, es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23.1, es-abstract@^1.23.2, es-abstract@^1.23.3: version "1.23.3" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.23.3.tgz#8f0c5a35cd215312573c5a27c87dfd6c881a0aa0" integrity sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A== @@ -4125,6 +4243,26 @@ es-errors@^1.2.1, es-errors@^1.3.0: resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== +es-iterator-helpers@^1.0.19: + version "1.0.19" + resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.0.19.tgz#117003d0e5fec237b4b5c08aded722e0c6d50ca8" + integrity sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.3" + es-errors "^1.3.0" + es-set-tostringtag "^2.0.3" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + globalthis "^1.0.3" + has-property-descriptors "^1.0.2" + has-proto "^1.0.3" + has-symbols "^1.0.3" + internal-slot "^1.0.7" + iterator.prototype "^1.1.2" + safe-array-concat "^1.1.2" + es-object-atoms@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941" @@ -4182,6 +4320,11 @@ escape-string-regexp@^1.0.5: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== +escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + escodegen@^1.8.1: version "1.14.3" resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-1.14.3.tgz#4e7b81fba61581dc97582ed78cab7f0e8d63f503" @@ -4236,6 +4379,18 @@ eslint-config-salesforce@^2.2.0: resolved "https://registry.yarnpkg.com/eslint-config-salesforce/-/eslint-config-salesforce-2.2.0.tgz#04b6cf07dcbaabc32fc9edb0915860497db55c30" integrity sha512-0zUEFJ2nNpMvVO3MgKEDUTGtaFZjL3xEIErr5h+BOft+OhGoIvZBNPnBBu12lvv29ylqIAQz5SwoVCCUzBhyPQ== +eslint-config-xo-react@^0.27.0: + version "0.27.0" + resolved "https://registry.yarnpkg.com/eslint-config-xo-react/-/eslint-config-xo-react-0.27.0.tgz#aeb7593bf3d8fb9fc7fecfcbd7240f0daddb5cb0" + integrity sha512-wiV215xQIn71XZyyVfaOXHaFpR1B14IJttwOjMi/eqUK1s+ojJdHr7eHqTLaGUfh6FKgWha1QNwePlIXx7mBUg== + +eslint-config-xo@^0.45.0: + version "0.45.0" + resolved "https://registry.yarnpkg.com/eslint-config-xo/-/eslint-config-xo-0.45.0.tgz#80e11c386aad07070bf103c04057dfa3d07b9705" + integrity sha512-T30F2S2HKKmr/RoHopKE7wMUMWrsLMab1qFl2WyFJjETbD+l7p4hSQWpTVGW7TEbSKG1QBekwf6Jn9ZDPA6thA== + dependencies: + confusing-browser-globals "1.0.11" + eslint-import-resolver-node@^0.3.9: version "0.3.9" resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz#d4eaac52b8a2e7c3cd1903eb00f7e053356118ac" @@ -4295,6 +4450,36 @@ eslint-plugin-jsdoc@^46.10.1: semver "^7.5.4" spdx-expression-parse "^4.0.0" +eslint-plugin-react-hooks@^4.6.2: + version "4.6.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz#c829eb06c0e6f484b3fbb85a97e57784f328c596" + integrity sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ== + +eslint-plugin-react@^7.34.3: + version "7.34.4" + resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.34.4.tgz#1f0dc313a0937db7ce15fd1f6c3d77e70f3e02fb" + integrity sha512-Np+jo9bUwJNxCsT12pXtrGhJgT3T44T1sHhn1Ssr42XFn8TES0267wPGo5nNrMHi8qkyimDAX2BUmkf9pSaVzA== + dependencies: + array-includes "^3.1.8" + array.prototype.findlast "^1.2.5" + array.prototype.flatmap "^1.3.2" + array.prototype.toreversed "^1.1.2" + array.prototype.tosorted "^1.1.4" + doctrine "^2.1.0" + es-iterator-helpers "^1.0.19" + estraverse "^5.3.0" + hasown "^2.0.2" + jsx-ast-utils "^2.4.1 || ^3.0.0" + minimatch "^3.1.2" + object.entries "^1.1.8" + object.fromentries "^2.0.8" + object.values "^1.2.0" + prop-types "^15.8.1" + resolve "^2.0.0-next.5" + semver "^6.3.1" + string.prototype.matchall "^4.0.11" + string.prototype.repeat "^1.0.0" + eslint-plugin-sf-plugin@^1.18.11: version "1.18.11" resolved "https://registry.yarnpkg.com/eslint-plugin-sf-plugin/-/eslint-plugin-sf-plugin-1.18.11.tgz#bf320e0dbbad4979e23842b60efd1db3b8611c83" @@ -4420,7 +4605,7 @@ estraverse@^4.2.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== -estraverse@^5.1.0, estraverse@^5.2.0: +estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== @@ -4573,6 +4758,13 @@ faye@1.4.0, faye@^1.4.0: tough-cookie "*" tunnel-agent "*" +figures@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-6.1.0.tgz#935479f51865fa7479f6fa94fc6fc7ac14e62c4a" + integrity sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg== + dependencies: + is-unicode-supported "^2.0.0" + file-entry-cache@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" @@ -4720,7 +4912,7 @@ function-bind@^1.1.2: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== -function.prototype.name@^1.1.6: +function.prototype.name@^1.1.5, function.prototype.name@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.6.tgz#cdf315b7d90ee77a4c6ee216c3c3362da07533fd" integrity sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg== @@ -5238,6 +5430,11 @@ indent-string@^4.0.0: resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== +indent-string@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-5.0.0.tgz#4fd2980fccaf8622d14c64d694f4cf33c81951a5" + integrity sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg== + inflight@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" @@ -5256,6 +5453,36 @@ ini@^1.3.4: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== +ink@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ink/-/ink-5.0.1.tgz#f2ef9796a3911830c3995dedd227ec84ae27de4b" + integrity sha512-ae4AW/t8jlkj/6Ou21H2av0wxTk8vrGzXv+v2v7j4in+bl1M5XRMVbfNghzhBokV++FjF8RBDJvYo+ttR9YVRg== + dependencies: + "@alcalzone/ansi-tokenize" "^0.1.3" + ansi-escapes "^7.0.0" + ansi-styles "^6.2.1" + auto-bind "^5.0.1" + chalk "^5.3.0" + cli-boxes "^3.0.0" + cli-cursor "^4.0.0" + cli-truncate "^4.0.0" + code-excerpt "^4.0.0" + indent-string "^5.0.0" + is-in-ci "^0.1.0" + lodash "^4.17.21" + patch-console "^2.0.0" + react-reconciler "^0.29.0" + scheduler "^0.23.0" + signal-exit "^3.0.7" + slice-ansi "^7.1.0" + stack-utils "^2.0.6" + string-width "^7.0.0" + type-fest "^4.8.3" + widest-line "^5.0.0" + wrap-ansi "^9.0.0" + ws "^8.15.0" + yoga-wasm-web "~0.3.3" + internal-slot@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.7.tgz#c06dcca3ed874249881007b0a5523b172a190802" @@ -5291,6 +5518,13 @@ is-arrayish@^0.2.1: resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== +is-async-function@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.0.0.tgz#8e4418efd3e5d3a6ebb0164c05ef5afb69aa9646" + integrity sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA== + dependencies: + has-tostringtag "^1.0.0" + is-bigint@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" @@ -5339,7 +5573,7 @@ is-data-view@^1.0.1: dependencies: is-typed-array "^1.1.13" -is-date-object@^1.0.1: +is-date-object@^1.0.1, is-date-object@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== @@ -5361,11 +5595,23 @@ is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== +is-finalizationregistry@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz#c8749b65f17c133313e661b1289b95ad3dbd62e6" + integrity sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw== + dependencies: + call-bind "^1.0.2" + is-fullwidth-code-point@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== +is-fullwidth-code-point@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz#fae3167c729e7463f8461ce512b080a49268aa88" + integrity sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ== + is-fullwidth-code-point@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz#9609efced7c2f97da7b60145ef481c787c7ba704" @@ -5373,6 +5619,13 @@ is-fullwidth-code-point@^5.0.0: dependencies: get-east-asian-width "^1.0.0" +is-generator-function@^1.0.10: + version "1.0.10" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72" + integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A== + dependencies: + has-tostringtag "^1.0.0" + is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -5380,6 +5633,11 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" +is-in-ci@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-in-ci/-/is-in-ci-0.1.0.tgz#5e07d6a02ec3a8292d3f590973357efa3fceb0d3" + integrity sha512-d9PXLEY0v1iJ64xLiQMJ51J128EYHAaOR4yZqQi8aHGfw6KgifM3/Viw1oZZ1GCVmb3gBuyhLyHj0HgR2DhSXQ== + is-inside-container@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-inside-container/-/is-inside-container-1.0.0.tgz#e81fba699662eb31dbdaf26766a61d4814717ea4" @@ -5387,6 +5645,11 @@ is-inside-container@^1.0.0: dependencies: is-docker "^3.0.0" +is-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + is-negative-zero@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" @@ -5442,6 +5705,11 @@ is-retry-allowed@^1.1.0: resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4" integrity sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg== +is-set@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz#1237f1cba059cdb62431d378dcc37d9680181688" @@ -5492,6 +5760,16 @@ is-unicode-supported@^0.1.0: resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== +is-unicode-supported@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.0.0.tgz#fdf32df9ae98ff6ab2cedc155a5a6e895701c451" + integrity sha512-FRdAyx5lusK1iHG0TWpVtk9+1i+GjrzRffhDg4ovQ7mcidMQ6mj+MhKPmvh7Xwyv5gIS06ns49CA7Sqg7lC22Q== + +is-weakmap@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" + integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== + is-weakref@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" @@ -5499,6 +5777,14 @@ is-weakref@^1.0.2: dependencies: call-bind "^1.0.2" +is-weakset@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.3.tgz#e801519df8c0c43e12ff2834eead84ec9e624007" + integrity sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ== + dependencies: + call-bind "^1.0.7" + get-intrinsic "^1.2.4" + is-windows@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" @@ -5615,6 +5901,17 @@ istanbul-reports@^3.0.2, istanbul-reports@^3.1.7: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" +iterator.prototype@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/iterator.prototype/-/iterator.prototype-1.1.2.tgz#5e29c8924f01916cb9335f1ff80619dcff22b0c0" + integrity sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w== + dependencies: + define-properties "^1.2.1" + get-intrinsic "^1.2.1" + has-symbols "^1.0.3" + reflect.getprototypeof "^1.0.4" + set-function-name "^2.0.1" + jackspeak@^3.1.2: version "3.4.0" resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.0.tgz#a75763ff36ad778ede6a156d8ee8b124de445b4a" @@ -5639,7 +5936,7 @@ joycon@^3.1.1: resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03" integrity sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw== -js-tokens@^4.0.0: +"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== @@ -5784,6 +6081,16 @@ jsonwebtoken@9.0.2: ms "^2.1.1" semver "^7.5.4" +"jsx-ast-utils@^2.4.1 || ^3.0.0": + version "3.3.5" + resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a" + integrity sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ== + dependencies: + array-includes "^3.1.6" + array.prototype.flat "^1.3.1" + object.assign "^4.1.4" + object.values "^1.1.6" + jszip@3.10.1, jszip@^3.10.1: version "3.10.1" resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2" @@ -6026,6 +6333,13 @@ lolex@^5.0.1: dependencies: "@sinonjs/commons" "^1.7.0" +loose-envify@^1.1.0, loose-envify@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" + integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== + dependencies: + js-tokens "^3.0.0 || ^4.0.0" + loupe@^2.3.6: version "2.3.7" resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.7.tgz#6e69b7d4db7d3ab436328013d37d1c8c3540c697" @@ -6504,6 +6818,11 @@ nyc@^15.1.0: test-exclude "^6.0.0" yargs "^15.0.2" +object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + object-inspect@^1.13.1: version "1.13.1" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" @@ -6514,7 +6833,7 @@ object-keys@^1.1.1: resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== -object.assign@^4.1.5: +object.assign@^4.1.4, object.assign@^4.1.5: version "4.1.5" resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.5.tgz#3a833f9ab7fdb80fc9e8d2300c803d216d8fdbb0" integrity sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ== @@ -6524,7 +6843,16 @@ object.assign@^4.1.5: has-symbols "^1.0.3" object-keys "^1.1.1" -object.fromentries@^2.0.7: +object.entries@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.8.tgz#bffe6f282e01f4d17807204a24f8edd823599c41" + integrity sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + +object.fromentries@^2.0.7, object.fromentries@^2.0.8: version "2.0.8" resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.8.tgz#f7195d8a9b97bd95cbc1999ea939ecd1a2b00c65" integrity sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ== @@ -6543,7 +6871,7 @@ object.groupby@^1.0.1: define-properties "^1.2.1" es-abstract "^1.23.2" -object.values@^1.1.7: +object.values@^1.1.6, object.values@^1.1.7, object.values@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.0.tgz#65405a9d92cee68ac2d303002e0b8470a4d9ab1b" integrity sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ== @@ -6763,6 +7091,11 @@ pascal-case@^3.1.2: no-case "^3.0.4" tslib "^2.0.3" +patch-console@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/patch-console/-/patch-console-2.0.0.tgz#9023f4665840e66f40e9ce774f904a63167433bb" + integrity sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA== + path-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/path-case/-/path-case-3.0.4.tgz#9168645334eb942658375c56f80b4c0cb5f82c6f" @@ -6963,6 +7296,15 @@ process@^0.11.10: resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A== +prop-types@^15.8.1: + version "15.8.1" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" + integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.13.1" + proper-lockfile@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f" @@ -7041,6 +7383,26 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" +react-is@^16.13.1: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + +react-reconciler@^0.29.0: + version "0.29.2" + resolved "https://registry.yarnpkg.com/react-reconciler/-/react-reconciler-0.29.2.tgz#8ecfafca63549a4f4f3e4c1e049dd5ad9ac3a54f" + integrity sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg== + dependencies: + loose-envify "^1.1.0" + scheduler "^0.23.2" + +react@^18.3.1: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" + integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== + dependencies: + loose-envify "^1.1.0" + read-pkg-up@^7.0.1: version "7.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507" @@ -7127,6 +7489,19 @@ redeyed@~2.1.0: dependencies: esprima "~4.0.0" +reflect.getprototypeof@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz#3ab04c32a8390b770712b7a8633972702d278859" + integrity sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.1" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + globalthis "^1.0.3" + which-builtin-type "^1.1.3" + regexp-tree@^0.1.27: version "0.1.27" resolved "https://registry.yarnpkg.com/regexp-tree/-/regexp-tree-0.1.27.tgz#2198f0ef54518ffa743fe74d983b56ffd631b6cd" @@ -7207,6 +7582,15 @@ resolve@^1.1.6, resolve@^1.10.0, resolve@^1.22.4: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +resolve@^2.0.0-next.5: + version "2.0.0-next.5" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.5.tgz#6b0ec3107e671e52b68cd068ef327173b90dc03c" + integrity sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA== + dependencies: + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + responselike@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/responselike/-/responselike-2.0.1.tgz#9a0bc8fdc252f3fb1cca68b016591059ba1422bc" @@ -7221,6 +7605,14 @@ responselike@^3.0.0: dependencies: lowercase-keys "^3.0.0" +restore-cursor@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-4.0.0.tgz#519560a4318975096def6e609d44100edaa4ccb9" + integrity sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + retry@0.13.1: version "0.13.1" resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" @@ -7299,6 +7691,13 @@ sax@>=0.6.0: resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f" integrity sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg== +scheduler@^0.23.0, scheduler@^0.23.2: + version "0.23.2" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" + integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== + dependencies: + loose-envify "^1.1.0" + secure-json-parse@^2.4.0: version "2.7.0" resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.7.0.tgz#5a5f9cd6ae47df23dba3151edd06855d47e09862" @@ -7369,7 +7768,7 @@ set-function-length@^1.2.1: gopd "^1.0.1" has-property-descriptors "^1.0.2" -set-function-name@^2.0.1: +set-function-name@^2.0.1, set-function-name@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== @@ -7423,7 +7822,7 @@ shiki@^0.14.7: vscode-oniguruma "^1.7.0" vscode-textmate "^8.0.0" -side-channel@^1.0.4: +side-channel@^1.0.4, side-channel@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== @@ -7433,7 +7832,7 @@ side-channel@^1.0.4: get-intrinsic "^1.2.4" object-inspect "^1.13.1" -signal-exit@^3.0.2, signal-exit@^3.0.3: +signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== @@ -7521,6 +7920,14 @@ slash@^5.1.0: resolved "https://registry.yarnpkg.com/slash/-/slash-5.1.0.tgz#be3adddcdf09ac38eebe8dcdc7b1a57a75b095ce" integrity sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg== +slice-ansi@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-5.0.0.tgz#b73063c57aa96f9cd881654b15294d95d285c42a" + integrity sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ== + dependencies: + ansi-styles "^6.0.0" + is-fullwidth-code-point "^4.0.0" + slice-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-7.1.0.tgz#cd6b4655e298a8d1bdeb04250a433094b347b9a9" @@ -7671,6 +8078,13 @@ srcset@^5.0.0: resolved "https://registry.yarnpkg.com/srcset/-/srcset-5.0.1.tgz#e660a728f195419e4afa95121099bc9efb7a1e36" integrity sha512-/P1UYbGfJVlxZag7aABNRrulEXAwCSDo7fklafOQrantuPTDmYgijJMks2zusPCVzgW9+4P69mq7w6pYuZpgxw== +stack-utils@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" + integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== + dependencies: + escape-string-regexp "^2.0.0" + static-eval@2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/static-eval/-/static-eval-2.0.2.tgz#2d1759306b1befa688938454c546b7871f806a42" @@ -7696,7 +8110,7 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" -string-width@^7.2.0: +string-width@^7.0.0, string-width@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc" integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== @@ -7705,6 +8119,32 @@ string-width@^7.2.0: get-east-asian-width "^1.0.0" strip-ansi "^7.1.0" +string.prototype.matchall@^4.0.11: + version "4.0.11" + resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz#1092a72c59268d2abaad76582dccc687c0297e0a" + integrity sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-symbols "^1.0.3" + internal-slot "^1.0.7" + regexp.prototype.flags "^1.5.2" + set-function-name "^2.0.2" + side-channel "^1.0.6" + +string.prototype.repeat@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz#e90872ee0308b29435aa26275f6e1b762daee01a" + integrity sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w== + dependencies: + define-properties "^1.1.3" + es-abstract "^1.17.5" + string.prototype.trim@^1.2.9: version "1.2.9" resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4" @@ -8033,6 +8473,11 @@ type-fest@^1.0.2: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== +type-fest@^4.8.3: + version "4.22.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.22.0.tgz#da4fc735652e17ef693d2b8dc4f65d93f5fd4ef9" + integrity sha512-hxMO1k4ip1uTVGgPbs1hVpYyhz2P91A6tQyH2H9POx3U6T3MdhIcfY8L2hRu/LRmzPFdfduOS0RIDjFlP2urPw== + typed-array-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz#1867c5d83b20fcb5ccf32649e5e2fc7424474ff3" @@ -8272,12 +8717,40 @@ which-boxed-primitive@^1.0.2: is-string "^1.0.5" is-symbol "^1.0.3" +which-builtin-type@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.1.3.tgz#b1b8443707cc58b6e9bf98d32110ff0c2cbd029b" + integrity sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw== + dependencies: + function.prototype.name "^1.1.5" + has-tostringtag "^1.0.0" + is-async-function "^2.0.0" + is-date-object "^1.0.5" + is-finalizationregistry "^1.0.2" + is-generator-function "^1.0.10" + is-regex "^1.1.4" + is-weakref "^1.0.2" + isarray "^2.0.5" + which-boxed-primitive "^1.0.2" + which-collection "^1.0.1" + which-typed-array "^1.1.9" + +which-collection@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== + dependencies: + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" + which-module@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409" integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ== -which-typed-array@^1.1.14, which-typed-array@^1.1.15: +which-typed-array@^1.1.14, which-typed-array@^1.1.15, which-typed-array@^1.1.9: version "1.1.15" resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d" integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== @@ -8302,6 +8775,13 @@ widest-line@^3.1.0: dependencies: string-width "^4.0.0" +widest-line@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-5.0.0.tgz#b74826a1e480783345f0cd9061b49753c9da70d0" + integrity sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA== + dependencies: + string-width "^7.0.0" + wireit@^0.14.4: version "0.14.4" resolved "https://registry.yarnpkg.com/wireit/-/wireit-0.14.4.tgz#4c8913a4a74cb15b5381c4b8276c5d71c27f54c5" @@ -8355,6 +8835,15 @@ wrap-ansi@^8.1.0: string-width "^5.0.1" strip-ansi "^7.0.1" +wrap-ansi@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.0.tgz#1a3dc8b70d85eeb8398ddfb1e4a02cd186e58b3e" + integrity sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q== + dependencies: + ansi-styles "^6.2.1" + string-width "^7.0.0" + strip-ansi "^7.1.0" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -8370,6 +8859,11 @@ write-file-atomic@^3.0.0: signal-exit "^3.0.2" typedarray-to-buffer "^3.1.5" +ws@^8.15.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" + integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== + xml2js@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.2.tgz#dd0b630083aa09c161e25a4d0901e2b2a929b499" @@ -8498,3 +8992,8 @@ yoctocolors-cjs@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz#f4b905a840a37506813a7acaa28febe97767a242" integrity sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA== + +yoga-wasm-web@~0.3.3: + version "0.3.3" + resolved "https://registry.yarnpkg.com/yoga-wasm-web/-/yoga-wasm-web-0.3.3.tgz#eb8e9fcb18e5e651994732f19a220cb885d932ba" + integrity sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA== From 5487d5831dee3c493afeba9aa07271f397a31294 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Tue, 23 Jul 2024 13:10:18 -0600 Subject: [PATCH 02/15] feat: more flexibility --- src/commands/project/deploy/start.ts | 50 ++-- src/commands/project/retrieve/start.ts | 36 +-- src/components/design-elements.ts | 1 + src/components/stages.tsx | 315 +++++++++++++++---------- 4 files changed, 237 insertions(+), 165 deletions(-) diff --git a/src/commands/project/deploy/start.ts b/src/commands/project/deploy/start.ts index 550737b9..82479173 100644 --- a/src/commands/project/deploy/start.ts +++ b/src/commands/project/deploy/start.ts @@ -210,23 +210,41 @@ export default class DeployMetadata extends SfCommand { title, stages: ['Preparing', 'Deploying Metadata', 'Running Tests', 'Updating Source Tracking', 'Done'], jsonEnabled: this.jsonEnabled(), - info: [ + preInfoBlock: [ { - label: 'Status', - get: (data) => data?.mdapiDeploy && mdTransferMessages.getMessage(data?.mdapiDeploy?.status), - bold: true, + type: 'message', + get: (data) => + data?.apiData && + messages.getMessage('apiVersionMsgDetailed', [ + flags['dry-run'] ? 'Deploying (dry-run)' : 'Deploying', + // technically manifestVersion can be undefined, but only on raw mdapi deployments. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + flags['metadata-dir'] ? '' : `v${data.apiData.manifestVersion}`, + username, + data.apiData.apiVersion, + data.apiData.webService, + ]), }, - { label: 'Deploy ID', get: (data) => data?.mdapiDeploy?.id, - static: true, + type: 'static-key-value', }, { label: 'Target Org', get: (data) => data?.targetOrg, - static: true, + type: 'static-key-value', }, + ], + postInfoBlock: [ + { + label: 'Status', + get: (data) => data?.mdapiDeploy && mdTransferMessages.getMessage(data?.mdapiDeploy?.status), + bold: true, + type: 'dynamic-key-value', + }, + ], + stageInfoBlock: [ { label: 'Components', get: (data) => @@ -237,6 +255,7 @@ export default class DeployMetadata extends SfCommand { )}%)` : undefined, stage: 'Deploying Metadata', + type: 'dynamic-key-value', }, { label: 'Tests', @@ -247,6 +266,7 @@ export default class DeployMetadata extends SfCommand { }` : undefined, stage: 'Running Tests', + type: 'dynamic-key-value', }, { label: 'Members', @@ -256,25 +276,13 @@ export default class DeployMetadata extends SfCommand { data.sourceMemberPolling.original }`, stage: 'Updating Source Tracking', + type: 'dynamic-key-value', }, ], }); const lifecycle = Lifecycle.getInstance(); - // eslint-disable-next-line @typescript-eslint/require-await - lifecycle.on('apiVersionDeploy', async (apiData: DeployVersionData) => { - ms.addMessage( - messages.getMessage('apiVersionMsgDetailed', [ - flags['dry-run'] ? 'Deploying (dry-run)' : 'Deploying', - // technically manifestVersion can be undefined, but only on raw mdapi deployments. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - flags['metadata-dir'] ? '' : `v${apiData.manifestVersion}`, - username, - apiData.apiVersion, - apiData.webService, - ]) - ); - }); + lifecycle.on('apiVersionDeploy', async (apiData: DeployVersionData) => Promise.resolve(ms.updateData({ apiData }))); const { deploy } = await executeDeploy( { diff --git a/src/commands/project/retrieve/start.ts b/src/commands/project/retrieve/start.ts index 7e826086..8bc0d8ed 100644 --- a/src/commands/project/retrieve/start.ts +++ b/src/commands/project/retrieve/start.ts @@ -164,23 +164,35 @@ export default class RetrieveMetadata extends SfCommand { const stages = ['Preparing retrieve request', 'Sending request to org', 'Waiting for the org to respond', 'Done']; const ms = new MultiStageComponent<{ status: string; - apiVersion: string; - metadataApiVersion: string; - targetOrg: string; + apiData: RetrieveVersionData; }>({ stages, title: 'Retrieving Metadata', jsonEnabled: this.jsonEnabled(), - info: [ + preInfoBlock: [ + { + type: 'message', + get: (data) => + data?.apiData && + messages.getMessage('apiVersionMsgDetailed', [ + 'Retrieving', + `v${data.apiData.manifestVersion}`, + flags['target-org'].getUsername(), + data.apiData.apiVersion, + ]), + }, + ], + postInfoBlock: [ { label: 'Status', get: (data) => data?.status, bold: true, + type: 'dynamic-key-value', }, ], }); - ms.goto(messages.getMessage('spinner.start'), { targetOrg: flags['target-org'].getUsername() }); + ms.goto(messages.getMessage('spinner.start')); const { componentSetFromNonDeletes, fileResponsesFromDelete = [] } = await buildRetrieveAndDeleteTargets( flags, @@ -203,17 +215,9 @@ export default class RetrieveMetadata extends SfCommand { this.retrieveResult = new RetrieveResult({} as MetadataApiRetrieveStatus, componentSetFromNonDeletes); if (componentSetFromNonDeletes.size !== 0 || retrieveOpts.packageOptions?.length) { - // eslint-disable-next-line @typescript-eslint/require-await - Lifecycle.getInstance().on('apiVersionRetrieve', async (apiData: RetrieveVersionData) => { - ms.addMessage( - messages.getMessage('apiVersionMsgDetailed', [ - 'Retrieving', - `v${apiData.manifestVersion}`, - flags['target-org'].getUsername(), - apiData.apiVersion, - ]) - ); - }); + Lifecycle.getInstance().on('apiVersionRetrieve', async (apiData: RetrieveVersionData) => + Promise.resolve(ms.updateData({ apiData })) + ); const retrieve = await componentSetFromNonDeletes.retrieve(retrieveOpts); ms.goto(messages.getMessage('spinner.polling'), { status: 'Pending' }); diff --git a/src/components/design-elements.ts b/src/components/design-elements.ts index 265d4b04..86e71eed 100644 --- a/src/components/design-elements.ts +++ b/src/components/design-elements.ts @@ -12,6 +12,7 @@ export const icons = { skipped: figures.circle, completed: figures.tick, failed: figures.cross, + current: figures.play, }; export const spinners: Record = { diff --git a/src/components/stages.tsx b/src/components/stages.tsx index 21371864..4b11beb5 100644 --- a/src/components/stages.tsx +++ b/src/components/stages.tsx @@ -25,6 +25,12 @@ const isInCi = ('CI' in env || 'CONTINUOUS_INTEGRATION' in env || Object.keys(env).some((key) => key.startsWith('CI_'))); type Info> = { + /** + * key-value: Display a key-value pair with a spinner. + * static-key-value: Display a key-value pair without a spinner. + * message: Display a message. + */ + type: 'dynamic-key-value' | 'static-key-value' | 'message'; /** * Color of the value. */ @@ -36,33 +42,40 @@ type Info> = { * @param data The data property on the MultiStageComponent. * @returns {string | undefined} */ - get?: (data?: T) => string | undefined; + get: (data?: T) => string | undefined; /** * Whether the value should be bold. */ bold?: boolean; +}; + +type KeyValuePair> = Info & { /** - * Whether the value should be a static key-value pair (not a spinner component). - */ - static?: boolean; - /** - * Label to display next to the value. + * Label of the key-value pair. */ label: string; - /** - * Stage to display the value on. If not provided, the value will be displayed at the bottom of the stages component. - */ - stage?: string; + type: 'dynamic-key-value' | 'static-key-value'; +}; + +type SimpleMessage> = Info & { + type: 'message'; }; -type FormattedInfo = { +type InfoBlock> = Array | SimpleMessage>; +type StageInfoBlock> = Array< + (KeyValuePair & { stage: string }) | (SimpleMessage & { stage: string }) +>; + +type FormattedKeyValue = { readonly color?: string; readonly isBold?: boolean; - readonly isStatic?: boolean; - readonly label: string; + // eslint-disable-next-line react/no-unused-prop-types + readonly label?: string; readonly value: string | undefined; // eslint-disable-next-line react/no-unused-prop-types readonly stage?: string; + // eslint-disable-next-line react/no-unused-prop-types + readonly type: 'dynamic-key-value' | 'static-key-value' | 'message'; }; type MultiStageComponentOptions> = { @@ -77,13 +90,11 @@ type MultiStageComponentOptions> = { /** * Information to display at the bottom of the stages component. */ - readonly info?: Array>; + readonly postInfoBlock?: Array | SimpleMessage>; /** - * Messages to display at the bottom of the stages component. - * - * This will be rendered between the stages and the info section. + * Information to display below the title but above the stages. */ - readonly messages?: string[]; + readonly preInfoBlock?: Array | SimpleMessage>; /** * Whether to show the total elapsed time. Defaults to true */ @@ -92,6 +103,10 @@ type MultiStageComponentOptions> = { * Whether to show the time spent on each stage. Defaults to true */ readonly showStageTime?: boolean; + /** + * Information to display for a specific stage. Each object must have a stage property set. + */ + readonly stageInfoBlock?: StageInfoBlock; /** * The unit to use for the timer. Defaults to 'ms' */ @@ -110,8 +125,9 @@ type MultiStageComponentOptions> = { type StagesProps = { readonly error?: Error | undefined; - readonly info?: FormattedInfo[]; - readonly messages?: string[]; + readonly postInfoBlock?: FormattedKeyValue[]; + readonly preInfoBlock?: FormattedKeyValue[]; + readonly stageInfoBlock?: FormattedKeyValue[]; readonly title: string; readonly hasElapsedTime?: boolean; readonly hasStageTime?: boolean; @@ -119,41 +135,67 @@ type StagesProps = { readonly stageTracker: StageTracker; }; -function StaticKeyValue({ label, value, isBold, color, isStatic }: FormattedInfo): React.ReactNode { - if (!value || !isStatic) return; +function StaticKeyValue({ label, value, isBold, color }: FormattedKeyValue): React.ReactNode { + if (!value) return; return ( - + {label}: {value} ); } -function Infos({ info, error, stage }: { info: FormattedInfo[]; error?: Error; stage?: string }): React.ReactNode { +function SimpleMessage({ value, color, isBold }: FormattedKeyValue): React.ReactNode { + if (!value) return; return ( - info + + {value} + + ); +} + +function Infos({ + keyValuePairs, + error, + stage, +}: { + keyValuePairs: FormattedKeyValue[]; + error?: Error; + stage?: string; +}): React.ReactNode { + return ( + keyValuePairs // If stage is provided, only show info for that stage // otherwise, show all infos that don't have a specified stage - .filter((i) => (stage ? i.stage === stage : !i.stage)) - .map((i) => - i.isStatic ? ( - - ) : ( - - {i.value && ( - - {i.value} - - )} - - ) - ) + .filter((kv) => (stage ? kv.stage === stage : !kv.stage)) + .map((kv) => { + const key = `${kv.label}-${kv.value}`; + if (kv.type === 'message') { + return ; + } + + if (kv.type === 'dynamic-key-value') { + return ( + + {kv.value && ( + + {kv.value} + + )} + + ); + } + + if (kv.type === 'static-key-value') { + return ; + } + }) ); } @@ -161,8 +203,9 @@ function Stages({ error, hasElapsedTime = true, hasStageTime = true, - info, - messages, + postInfoBlock, + preInfoBlock, + stageInfoBlock, stageTracker, timerUnit = 'ms', title, @@ -170,11 +213,10 @@ function Stages({ return ( - {messages && messages.length > 0 && ( + + {preInfoBlock && ( - {messages?.map((message) => ( - {message} - ))} + )} @@ -212,18 +254,18 @@ function Stages({ )} - {info && status !== 'pending' && status !== 'skipped' && ( + {stageInfoBlock && status !== 'pending' && status !== 'skipped' && ( - + )} ))} - {info && ( + {postInfoBlock && ( - + )} @@ -238,35 +280,43 @@ function Stages({ } class CIMultiStageComponent> { - private seenStages: Set; + private seenStages: Set = new Set(); private data?: Partial; private startTime: number | undefined; private startTimes: Map = new Map(); + private lastUpdateTime: number; - private readonly info?: Array>; + private readonly postInfoBlock?: InfoBlock; + private readonly preInfoBlock?: InfoBlock; + private readonly stageInfoBlock?: StageInfoBlock; private readonly stages: readonly string[] | string[]; private readonly title: string; private readonly hasElapsedTime?: boolean; private readonly hasStageTime?: boolean; private readonly timerUnit?: 'ms' | 's'; + private readonly messageTimeout = parseInt(env.SF_CI_MESSAGE_TIMEOUT ?? '5000', 10) ?? 5000; public constructor({ data, - info, + postInfoBlock, + preInfoBlock, showElapsedTime, showStageTime, + stageInfoBlock, stages, timerUnit, title, }: MultiStageComponentOptions) { this.title = title; this.stages = stages; - this.seenStages = new Set(); - this.info = info; + this.postInfoBlock = postInfoBlock; + this.preInfoBlock = preInfoBlock; this.hasElapsedTime = showElapsedTime ?? true; this.hasStageTime = showStageTime ?? true; + this.stageInfoBlock = stageInfoBlock; this.timerUnit = timerUnit ?? 'ms'; this.data = data; + this.lastUpdateTime = Date.now(); ux.stdout(`───── ${this.title} ─────`); ux.stdout('Steps:'); @@ -292,7 +342,16 @@ class CIMultiStageComponent> { // do nothing break; case 'current': - this.startTimes.set(stage, Date.now()); + if (Date.now() - this.lastUpdateTime < this.messageTimeout) break; + this.lastUpdateTime = Date.now(); + if (!this.startTimes.has(stage)) this.startTimes.set(stage, Date.now()); + ux.stdout(`${icons.current} ${capitalCase(stage)}...`); + this.printInfo(this.preInfoBlock, 3); + this.printInfo( + this.stageInfoBlock?.filter((info) => info.stage === stage), + 3 + ); + this.printInfo(this.postInfoBlock, 3); break; case 'failed': case 'skipped': @@ -306,10 +365,22 @@ class CIMultiStageComponent> { ? msInMostReadableFormat(elapsedTime) : secondsInMostReadableFormat(elapsedTime, 0); ux.stdout(`${icons[status]} ${capitalCase(stage)} (${displayTime})`); + this.printInfo(this.preInfoBlock, 3); + this.printInfo( + this.stageInfoBlock?.filter((info) => info.stage === stage), + 3 + ); + this.printInfo(this.postInfoBlock, 3); } else if (status === 'skipped') { ux.stdout(`${icons[status]} ${capitalCase(stage)} - Skipped`); } else { ux.stdout(`${icons[status]} ${capitalCase(stage)}`); + this.printInfo(this.preInfoBlock, 3); + this.printInfo( + this.stageInfoBlock?.filter((info) => info.stage === stage), + 3 + ); + this.printInfo(this.postInfoBlock, 3); } break; @@ -317,13 +388,6 @@ class CIMultiStageComponent> { // do nothing } } - - this.printInfo(); - } - - // eslint-disable-next-line class-methods-use-this - public addMessage(message: string): void { - ux.stdout(message); } public stop(stageTracker: StageTracker): void { @@ -334,18 +398,24 @@ class CIMultiStageComponent> { const displayTime = this.timerUnit === 'ms' ? msInMostReadableFormat(elapsedTime) : secondsInMostReadableFormat(elapsedTime, 0); ux.stdout(`Elapsed time: ${displayTime}`); + ux.stdout(); } - this.printInfo(); + this.printInfo(this.preInfoBlock); + this.printInfo(this.postInfoBlock); } - private printInfo(): void { - if (!this.info) return; - ux.stdout(); - for (const info of this.info) { - const formattedData = info.get ? info.get(this.data as T) : undefined; - if (formattedData) { - ux.stdout(`${info.label}: ${formattedData}`); + private printInfo(infoBlock: InfoBlock | StageInfoBlock | undefined, indent = 0): void { + const spaces = ' '.repeat(indent); + if (infoBlock?.length) { + for (const info of infoBlock) { + const formattedData = info.get ? info.get(this.data as T) : undefined; + if (!formattedData) continue; + if (info.type === 'message') { + ux.stdout(`${spaces}${formattedData}`); + } else { + ux.stdout(`${spaces}${info.label}: ${formattedData}`); + } } } } @@ -357,9 +427,10 @@ export class MultiStageComponent> implements D private ciInstance: CIMultiStageComponent | undefined; private stageTracker: StageTracker; private stopped = false; - private messages: string[]; - private readonly info?: Array>; + private readonly postInfoBlock?: InfoBlock; + private readonly preInfoBlock?: InfoBlock; + private readonly stageInfoBlock?: StageInfoBlock; private readonly stages: readonly string[] | string[]; private readonly title: string; private readonly hasElapsedTime?: boolean; @@ -368,11 +439,12 @@ export class MultiStageComponent> implements D public constructor({ data, - info, jsonEnabled, - messages, + postInfoBlock, + preInfoBlock, showElapsedTime, showStageTime, + stageInfoBlock, stages, timerUnit, title, @@ -380,53 +452,37 @@ export class MultiStageComponent> implements D this.data = data; this.stages = stages; this.title = title; - this.info = info; + this.postInfoBlock = postInfoBlock; + this.preInfoBlock = preInfoBlock; this.hasElapsedTime = showElapsedTime ?? true; this.hasStageTime = showStageTime ?? true; this.timerUnit = timerUnit ?? 'ms'; this.stageTracker = new StageTracker(stages); - this.messages = messages ?? []; - - if (!jsonEnabled) { - if (isInCi) { - this.ciInstance = new CIMultiStageComponent({ - stages, - title, - info, - showElapsedTime, - showStageTime, - timerUnit, - data, - jsonEnabled, - }); - } else { - this.inkInstance = render( - - ); - } - } - } + this.stageInfoBlock = stageInfoBlock; + + if (jsonEnabled) return; - public addMessage(message: string): void { - if (this.stopped) return; - this.messages.push(message); if (isInCi) { - this.ciInstance?.addMessage(message); + this.ciInstance = new CIMultiStageComponent({ + stages, + title, + postInfoBlock, + preInfoBlock, + showElapsedTime, + showStageTime, + stageInfoBlock, + timerUnit, + data, + jsonEnabled, + }); } else { - this.inkInstance?.rerender( + this.inkInstance = render( > implements D error={error} hasElapsedTime={this.hasElapsedTime} hasStageTime={this.hasStageTime} - info={this.formatInfo()} - messages={this.messages} + postInfoBlock={this.formatKeyValuePairs(this.postInfoBlock)} + preInfoBlock={this.formatKeyValuePairs(this.preInfoBlock)} + stageInfoBlock={this.formatKeyValuePairs(this.stageInfoBlock)} stageTracker={this.stageTracker} timerUnit={this.timerUnit} title={this.title} @@ -492,8 +549,9 @@ export class MultiStageComponent> implements D > implements D > implements D } } - private formatInfo(): FormattedInfo[] { + private formatKeyValuePairs(infoBlock: InfoBlock | StageInfoBlock | undefined): FormattedKeyValue[] { return ( - this.info?.map((info) => { + infoBlock?.map((info) => { const formattedData = info.get ? info.get(this.data as T) : undefined; return { value: formattedData, - label: info.label, isBold: info.bold, color: info.color, - isStatic: info.static, - stage: info.stage, + type: info.type, + ...(info.type !== 'message' ? { label: info.label } : {}), + ...('stage' in info ? { stage: info.stage } : {}), }; }) ?? [] ); From a68ad92773dc6da0a74f369ab696acb0562303af Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Wed, 24 Jul 2024 13:31:45 -0600 Subject: [PATCH 03/15] chore: rename things --- src/commands/project/deploy/start.ts | 20 ++-- src/commands/project/retrieve/start.ts | 4 +- src/components/stages.tsx | 122 ++++++++++++------------- 3 files changed, 73 insertions(+), 73 deletions(-) diff --git a/src/commands/project/deploy/start.ts b/src/commands/project/deploy/start.ts index 82479173..344b43eb 100644 --- a/src/commands/project/deploy/start.ts +++ b/src/commands/project/deploy/start.ts @@ -210,7 +210,7 @@ export default class DeployMetadata extends SfCommand { title, stages: ['Preparing', 'Deploying Metadata', 'Running Tests', 'Updating Source Tracking', 'Done'], jsonEnabled: this.jsonEnabled(), - preInfoBlock: [ + preStagesBlock: [ { type: 'message', get: (data) => @@ -225,6 +225,14 @@ export default class DeployMetadata extends SfCommand { data.apiData.webService, ]), }, + ], + postStagesBlock: [ + { + label: 'Status', + get: (data) => data?.mdapiDeploy && mdTransferMessages.getMessage(data?.mdapiDeploy?.status), + bold: true, + type: 'dynamic-key-value', + }, { label: 'Deploy ID', get: (data) => data?.mdapiDeploy?.id, @@ -236,15 +244,7 @@ export default class DeployMetadata extends SfCommand { type: 'static-key-value', }, ], - postInfoBlock: [ - { - label: 'Status', - get: (data) => data?.mdapiDeploy && mdTransferMessages.getMessage(data?.mdapiDeploy?.status), - bold: true, - type: 'dynamic-key-value', - }, - ], - stageInfoBlock: [ + stageSpecificBlock: [ { label: 'Components', get: (data) => diff --git a/src/commands/project/retrieve/start.ts b/src/commands/project/retrieve/start.ts index 8bc0d8ed..f8dff4d8 100644 --- a/src/commands/project/retrieve/start.ts +++ b/src/commands/project/retrieve/start.ts @@ -169,7 +169,7 @@ export default class RetrieveMetadata extends SfCommand { stages, title: 'Retrieving Metadata', jsonEnabled: this.jsonEnabled(), - preInfoBlock: [ + preStagesBlock: [ { type: 'message', get: (data) => @@ -182,7 +182,7 @@ export default class RetrieveMetadata extends SfCommand { ]), }, ], - postInfoBlock: [ + postStagesBlock: [ { label: 'Status', get: (data) => data?.status, diff --git a/src/components/stages.tsx b/src/components/stages.tsx index 4b11beb5..2586bece 100644 --- a/src/components/stages.tsx +++ b/src/components/stages.tsx @@ -90,11 +90,11 @@ type MultiStageComponentOptions> = { /** * Information to display at the bottom of the stages component. */ - readonly postInfoBlock?: Array | SimpleMessage>; + readonly postStagesBlock?: Array | SimpleMessage>; /** * Information to display below the title but above the stages. */ - readonly preInfoBlock?: Array | SimpleMessage>; + readonly preStagesBlock?: Array | SimpleMessage>; /** * Whether to show the total elapsed time. Defaults to true */ @@ -106,7 +106,7 @@ type MultiStageComponentOptions> = { /** * Information to display for a specific stage. Each object must have a stage property set. */ - readonly stageInfoBlock?: StageInfoBlock; + readonly stageSpecificBlock?: StageInfoBlock; /** * The unit to use for the timer. Defaults to 'ms' */ @@ -125,9 +125,9 @@ type MultiStageComponentOptions> = { type StagesProps = { readonly error?: Error | undefined; - readonly postInfoBlock?: FormattedKeyValue[]; - readonly preInfoBlock?: FormattedKeyValue[]; - readonly stageInfoBlock?: FormattedKeyValue[]; + readonly postStagesBlock?: FormattedKeyValue[]; + readonly preStagesBlock?: FormattedKeyValue[]; + readonly stageSpecificBlock?: FormattedKeyValue[]; readonly title: string; readonly hasElapsedTime?: boolean; readonly hasStageTime?: boolean; @@ -203,9 +203,9 @@ function Stages({ error, hasElapsedTime = true, hasStageTime = true, - postInfoBlock, - preInfoBlock, - stageInfoBlock, + postStagesBlock, + preStagesBlock, + stageSpecificBlock, stageTracker, timerUnit = 'ms', title, @@ -214,9 +214,9 @@ function Stages({ - {preInfoBlock && ( + {preStagesBlock && preStagesBlock.length > 0 && ( - + )} @@ -236,8 +236,8 @@ function Stages({ {status === 'completed' && ( - {icons.completed} - {capitalCase(stage)} + {icons.completed} + {capitalCase(stage)} )} @@ -254,18 +254,18 @@ function Stages({ )} - {stageInfoBlock && status !== 'pending' && status !== 'skipped' && ( + {stageSpecificBlock && stageSpecificBlock.length > 0 && status !== 'pending' && status !== 'skipped' && ( - + )} ))} - {postInfoBlock && ( + {postStagesBlock && postStagesBlock.length > 0 && ( - + )} @@ -286,9 +286,9 @@ class CIMultiStageComponent> { private startTimes: Map = new Map(); private lastUpdateTime: number; - private readonly postInfoBlock?: InfoBlock; - private readonly preInfoBlock?: InfoBlock; - private readonly stageInfoBlock?: StageInfoBlock; + private readonly postStagesBlock?: InfoBlock; + private readonly preStagesBlock?: InfoBlock; + private readonly stageSpecificBlock?: StageInfoBlock; private readonly stages: readonly string[] | string[]; private readonly title: string; private readonly hasElapsedTime?: boolean; @@ -298,22 +298,22 @@ class CIMultiStageComponent> { public constructor({ data, - postInfoBlock, - preInfoBlock, + postStagesBlock, + preStagesBlock, showElapsedTime, showStageTime, - stageInfoBlock, + stageSpecificBlock, stages, timerUnit, title, }: MultiStageComponentOptions) { this.title = title; this.stages = stages; - this.postInfoBlock = postInfoBlock; - this.preInfoBlock = preInfoBlock; + this.postStagesBlock = postStagesBlock; + this.preStagesBlock = preStagesBlock; this.hasElapsedTime = showElapsedTime ?? true; this.hasStageTime = showStageTime ?? true; - this.stageInfoBlock = stageInfoBlock; + this.stageSpecificBlock = stageSpecificBlock; this.timerUnit = timerUnit ?? 'ms'; this.data = data; this.lastUpdateTime = Date.now(); @@ -346,12 +346,12 @@ class CIMultiStageComponent> { this.lastUpdateTime = Date.now(); if (!this.startTimes.has(stage)) this.startTimes.set(stage, Date.now()); ux.stdout(`${icons.current} ${capitalCase(stage)}...`); - this.printInfo(this.preInfoBlock, 3); + this.printInfo(this.preStagesBlock, 3); this.printInfo( - this.stageInfoBlock?.filter((info) => info.stage === stage), + this.stageSpecificBlock?.filter((info) => info.stage === stage), 3 ); - this.printInfo(this.postInfoBlock, 3); + this.printInfo(this.postStagesBlock, 3); break; case 'failed': case 'skipped': @@ -365,22 +365,22 @@ class CIMultiStageComponent> { ? msInMostReadableFormat(elapsedTime) : secondsInMostReadableFormat(elapsedTime, 0); ux.stdout(`${icons[status]} ${capitalCase(stage)} (${displayTime})`); - this.printInfo(this.preInfoBlock, 3); + this.printInfo(this.preStagesBlock, 3); this.printInfo( - this.stageInfoBlock?.filter((info) => info.stage === stage), + this.stageSpecificBlock?.filter((info) => info.stage === stage), 3 ); - this.printInfo(this.postInfoBlock, 3); + this.printInfo(this.postStagesBlock, 3); } else if (status === 'skipped') { ux.stdout(`${icons[status]} ${capitalCase(stage)} - Skipped`); } else { ux.stdout(`${icons[status]} ${capitalCase(stage)}`); - this.printInfo(this.preInfoBlock, 3); + this.printInfo(this.preStagesBlock, 3); this.printInfo( - this.stageInfoBlock?.filter((info) => info.stage === stage), + this.stageSpecificBlock?.filter((info) => info.stage === stage), 3 ); - this.printInfo(this.postInfoBlock, 3); + this.printInfo(this.postStagesBlock, 3); } break; @@ -401,8 +401,8 @@ class CIMultiStageComponent> { ux.stdout(); } - this.printInfo(this.preInfoBlock); - this.printInfo(this.postInfoBlock); + this.printInfo(this.preStagesBlock); + this.printInfo(this.postStagesBlock); } private printInfo(infoBlock: InfoBlock | StageInfoBlock | undefined, indent = 0): void { @@ -428,9 +428,9 @@ export class MultiStageComponent> implements D private stageTracker: StageTracker; private stopped = false; - private readonly postInfoBlock?: InfoBlock; - private readonly preInfoBlock?: InfoBlock; - private readonly stageInfoBlock?: StageInfoBlock; + private readonly postStagesBlock?: InfoBlock; + private readonly preStagesBlock?: InfoBlock; + private readonly stageSpecificBlock?: StageInfoBlock; private readonly stages: readonly string[] | string[]; private readonly title: string; private readonly hasElapsedTime?: boolean; @@ -440,11 +440,11 @@ export class MultiStageComponent> implements D public constructor({ data, jsonEnabled, - postInfoBlock, - preInfoBlock, + postStagesBlock, + preStagesBlock, showElapsedTime, showStageTime, - stageInfoBlock, + stageSpecificBlock, stages, timerUnit, title, @@ -452,13 +452,13 @@ export class MultiStageComponent> implements D this.data = data; this.stages = stages; this.title = title; - this.postInfoBlock = postInfoBlock; - this.preInfoBlock = preInfoBlock; + this.postStagesBlock = postStagesBlock; + this.preStagesBlock = preStagesBlock; this.hasElapsedTime = showElapsedTime ?? true; this.hasStageTime = showStageTime ?? true; this.timerUnit = timerUnit ?? 'ms'; this.stageTracker = new StageTracker(stages); - this.stageInfoBlock = stageInfoBlock; + this.stageSpecificBlock = stageSpecificBlock; if (jsonEnabled) return; @@ -466,11 +466,11 @@ export class MultiStageComponent> implements D this.ciInstance = new CIMultiStageComponent({ stages, title, - postInfoBlock, - preInfoBlock, + postStagesBlock, + preStagesBlock, showElapsedTime, showStageTime, - stageInfoBlock, + stageSpecificBlock, timerUnit, data, jsonEnabled, @@ -480,9 +480,9 @@ export class MultiStageComponent> implements D > implements D error={error} hasElapsedTime={this.hasElapsedTime} hasStageTime={this.hasStageTime} - postInfoBlock={this.formatKeyValuePairs(this.postInfoBlock)} - preInfoBlock={this.formatKeyValuePairs(this.preInfoBlock)} - stageInfoBlock={this.formatKeyValuePairs(this.stageInfoBlock)} + postStagesBlock={this.formatKeyValuePairs(this.postStagesBlock)} + preStagesBlock={this.formatKeyValuePairs(this.preStagesBlock)} + stageSpecificBlock={this.formatKeyValuePairs(this.stageSpecificBlock)} stageTracker={this.stageTracker} timerUnit={this.timerUnit} title={this.title} @@ -549,9 +549,9 @@ export class MultiStageComponent> implements D > implements D Date: Wed, 7 Aug 2024 15:32:58 -0600 Subject: [PATCH 04/15] feat: use oclif/multi-stage-output --- package.json | 1 + src/commands/project/deploy/start.ts | 11 +- src/commands/project/retrieve/start.ts | 4 +- src/components/design-elements.ts | 21 - src/components/divider.tsx | 54 --- src/components/spinner.tsx | 112 ----- src/components/stage-tracker.ts | 81 ---- src/components/stages.tsx | 607 ------------------------- src/components/timer.tsx | 46 -- src/components/utils.ts | 40 -- yarn.lock | 12 + 11 files changed, 22 insertions(+), 967 deletions(-) delete mode 100644 src/components/design-elements.ts delete mode 100644 src/components/divider.tsx delete mode 100644 src/components/spinner.tsx delete mode 100644 src/components/stage-tracker.ts delete mode 100644 src/components/stages.tsx delete mode 100644 src/components/timer.tsx delete mode 100644 src/components/utils.ts diff --git a/package.json b/package.json index 3e002224..1b9739e7 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "bugs": "https://github.com/forcedotcom/cli/issues", "dependencies": { "@oclif/core": "^4.0.12", + "@oclif/multi-stage-output": "^0.1.2", "@salesforce/apex-node": "^7.0.4", "@salesforce/core": "^8.1.3", "@salesforce/kit": "^3.1.6", diff --git a/src/commands/project/deploy/start.ts b/src/commands/project/deploy/start.ts index 344b43eb..8f977f93 100644 --- a/src/commands/project/deploy/start.ts +++ b/src/commands/project/deploy/start.ts @@ -5,6 +5,7 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ +import { MultiStageOutput } from '@oclif/multi-stage-output'; import { EnvironmentVariable, Lifecycle, Messages, OrgConfigProperties, SfError } from '@salesforce/core'; import { DeployVersionData, MetadataApiDeployStatus } from '@salesforce/source-deploy-retrieve'; import { Duration } from '@salesforce/kit'; @@ -20,8 +21,6 @@ import { ConfigVars } from '../../../configMeta.js'; import { coverageFormattersFlag, fileOrDirFlag, testLevelFlag, testsFlag } from '../../../utils/flags.js'; import { writeConflictTable } from '../../../utils/conflicts.js'; import { getOptionalProject } from '../../../utils/project.js'; -import { MultiStageComponent } from '../../../components/stages.js'; -import { round } from '../../../components/utils.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-deploy-retrieve', 'deploy.metadata'); @@ -33,6 +32,11 @@ const sourceFormatFlags = 'Source Format'; const testFlags = 'Test'; const destructiveFlags = 'Delete'; +function round(value: number, precision: number): number { + const multiplier = Math.pow(10, precision || 0); + return Math.round(value * multiplier) / multiplier; +} + export default class DeployMetadata extends SfCommand { public static readonly description = messages.getMessage('description'); public static readonly summary = messages.getMessage('summary'); @@ -200,7 +204,7 @@ export default class DeployMetadata extends SfCommand { const username = flags['target-org'].getUsername(); const title = flags['dry-run'] ? 'Deploying Metadata (dry-run)' : 'Deploying Metadata'; - const ms = new MultiStageComponent<{ + const ms = new MultiStageOutput<{ mdapiDeploy: MetadataApiDeployStatus; sourceMemberPolling: SourceMemberPollingEvent; status: string; @@ -302,7 +306,6 @@ export default class DeployMetadata extends SfCommand { if (!deploy.id) { throw new SfError('The deploy id is not available.'); } - // this.log(`Deploy ID: ${ansis.bold(deploy.id)}`); if (flags.async) { if (flags['coverage-formatters']) { diff --git a/src/commands/project/retrieve/start.ts b/src/commands/project/retrieve/start.ts index f8dff4d8..139bb591 100644 --- a/src/commands/project/retrieve/start.ts +++ b/src/commands/project/retrieve/start.ts @@ -9,6 +9,7 @@ import { rm } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import * as fs from 'node:fs'; +import { MultiStageOutput } from '@oclif/multi-stage-output'; import { EnvironmentVariable, Lifecycle, Messages, OrgConfigProperties, SfError, SfProject } from '@salesforce/core'; import { RetrieveResult, @@ -27,7 +28,6 @@ import { SourceTracking, SourceConflictError } from '@salesforce/source-tracking import { Duration } from '@salesforce/kit'; import { Interfaces } from '@oclif/core'; -import { MultiStageComponent } from '../../../components/stages.js'; import { DEFAULT_ZIP_FILE_NAME, ensuredDirFlag, zipFileFlag } from '../../../utils/flags.js'; import { RetrieveResultFormatter } from '../../../formatters/retrieveResultFormatter.js'; import { MetadataRetrieveResultFormatter } from '../../../formatters/metadataRetrieveResultFormatter.js'; @@ -162,7 +162,7 @@ export default class RetrieveMetadata extends SfCommand { const zipFileName = flags['zip-file-name'] ?? DEFAULT_ZIP_FILE_NAME; const stages = ['Preparing retrieve request', 'Sending request to org', 'Waiting for the org to respond', 'Done']; - const ms = new MultiStageComponent<{ + const ms = new MultiStageOutput<{ status: string; apiData: RetrieveVersionData; }>({ diff --git a/src/components/design-elements.ts b/src/components/design-elements.ts deleted file mode 100644 index 86e71eed..00000000 --- a/src/components/design-elements.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (c) 2024, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import { type SpinnerName } from 'cli-spinners'; -import figures from 'figures'; - -export const icons = { - pending: figures.squareSmallFilled, - skipped: figures.circle, - completed: figures.tick, - failed: figures.cross, - current: figures.play, -}; - -export const spinners: Record = { - stage: process.platform === 'win32' ? 'line' : 'dots2', - info: process.platform === 'win32' ? 'line' : 'arc', -}; diff --git a/src/components/divider.tsx b/src/components/divider.tsx deleted file mode 100644 index 9a7b70c1..00000000 --- a/src/components/divider.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) 2024, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import { Box, Text } from 'ink'; -import React from 'react'; - -const getSideDividerWidth = (width: number, titleWidth: number): number => (width - titleWidth) / 2; -const getNumberOfCharsPerWidth = (char: string, width: number): number => width / char.length; - -const PAD = ' '; - -export function Divider({ - title = '', - width = 50, - padding = 1, - titlePadding = 1, - titleColor = 'white', - dividerChar = '─', - dividerColor = 'dim', -}: { - readonly title?: string; - readonly width?: number | 'full'; - readonly padding?: number; - readonly titleColor?: string; - readonly titlePadding?: number; - readonly dividerChar?: string; - readonly dividerColor?: string; -}): React.ReactNode { - const titleString = title ? `${PAD.repeat(titlePadding) + title + PAD.repeat(titlePadding)}` : ''; - const titleWidth = titleString.length; - const terminalWidth = process.stdout.columns ?? 80; - const widthToUse = width === 'full' ? terminalWidth - titlePadding : width > terminalWidth ? terminalWidth : width; - - const dividerWidth = getSideDividerWidth(widthToUse, titleWidth); - const numberOfCharsPerSide = getNumberOfCharsPerWidth(dividerChar, dividerWidth); - const dividerSideString = dividerChar.repeat(numberOfCharsPerSide); - - const paddingString = PAD.repeat(padding); - - return ( - - - {paddingString} - {dividerSideString} - {titleString} - {dividerSideString} - {paddingString} - - - ); -} diff --git a/src/components/spinner.tsx b/src/components/spinner.tsx deleted file mode 100644 index 44658148..00000000 --- a/src/components/spinner.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright (c) 2024, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import React, { useEffect, useState } from 'react'; -import spinners, { type SpinnerName } from 'cli-spinners'; -import { Box, Text } from 'ink'; -import { icons } from './design-elements.js'; - -export type UseSpinnerProps = { - /** - * Type of a spinner. - * See [cli-spinners](https://github.com/sindresorhus/cli-spinners) for available spinners. - * - * @default dots - */ - readonly type?: SpinnerName; -}; - -export type UseSpinnerResult = { - frame: string; -}; - -export function useSpinner({ type = 'dots' }: UseSpinnerProps): UseSpinnerResult { - const [frame, setFrame] = useState(0); - const spinner = spinners[type]; - - useEffect(() => { - const timer = setInterval(() => { - setFrame((previousFrame) => { - const isLastFrame = previousFrame === spinner.frames.length - 1; - return isLastFrame ? 0 : previousFrame + 1; - }); - }, spinner.interval); - - return (): void => { - clearInterval(timer); - }; - }, [spinner]); - - return { - frame: spinner.frames[frame] ?? '', - }; -} - -export type SpinnerProps = UseSpinnerProps & { - /** - * Label to show near the spinner. - */ - readonly label?: string; - readonly isBold?: boolean; - readonly labelPosition?: 'left' | 'right'; -}; - -export function Spinner({ isBold, label, type, labelPosition = 'right' }: SpinnerProps): React.ReactElement { - const { frame } = useSpinner({ type }); - - return ( - - {label && labelPosition === 'left' && {label}} - {isBold ? ( - - {frame} - - ) : ( - {frame} - )} - {label && labelPosition === 'right' && {label}} - - ); -} - -export function SpinnerOrError({ - error, - labelPosition = 'right', - ...props -}: SpinnerProps & { readonly error?: Error }): React.ReactElement { - if (error) { - return ( - - {props.label && labelPosition === 'left' && {props.label}} - {icons.failed} - {props.label && labelPosition === 'right' && {props.label}} - - ); - } - - return ; -} - -export function SpinnerOrErrorOrChildren({ - children, - error, - ...props -}: SpinnerProps & { - readonly children?: React.ReactNode; - readonly error?: Error; -}): React.ReactElement { - if (children) { - return ( - - {props.label && props.labelPosition === 'left' && {props.label}} - {children} - {props.label && props.labelPosition === 'right' && {props.label}} - - ); - } - - return ; -} diff --git a/src/components/stage-tracker.ts b/src/components/stage-tracker.ts deleted file mode 100644 index 09fa639a..00000000 --- a/src/components/stage-tracker.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (c) 2024, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import { Performance } from '@oclif/core/performance'; - -export type StageStatus = 'pending' | 'current' | 'completed' | 'skipped' | 'failed'; - -export class StageTracker extends Map { - public current: string | undefined; - private markers = new Map>(); - - public constructor(stages: readonly string[] | string[]) { - super(stages.map((stage) => [stage, 'pending'])); - } - - public set(stage: string, status: StageStatus): this { - if (status === 'current') { - this.current = stage; - } - return super.set(stage, status); - } - - public refresh(nextStage: string, opts?: { hasError?: boolean; isStopping?: boolean }): void { - const stages = [...this.keys()]; - for (const stage of stages) { - if (this.get(stage) === 'skipped') continue; - if (this.get(stage) === 'failed') continue; - - // .stop() was called with an error => set the stage to failed - if (nextStage === stage && opts?.hasError) { - this.set(stage, 'failed'); - this.stopMarker(stage); - continue; - } - - // .stop() was called without an error => set the stage to completed - if (nextStage === stage && opts?.isStopping) { - this.set(stage, 'completed'); - this.stopMarker(stage); - continue; - } - - // set the current stage - if (nextStage === stage) { - this.set(stage, 'current'); - // create a marker for the current stage if it doesn't exist - if (!this.markers.has(stage)) { - this.markers.set(stage, Performance.mark('MultiStageComponent', stage.replaceAll(' ', '-').toLowerCase())); - } - - continue; - } - - // any stage before the current stage should be marked as skipped if it's still pending - if (stages.indexOf(stage) < stages.indexOf(nextStage) && this.get(stage) === 'pending') { - this.set(stage, 'skipped'); - continue; - } - - // any stage before the current stage should be as completed (if it hasn't been marked as skipped or failed yet) - if (stages.indexOf(nextStage) > stages.indexOf(stage)) { - this.set(stage, 'completed'); - this.stopMarker(stage); - continue; - } - - // default to pending - this.set(stage, 'pending'); - } - } - - private stopMarker(stage: string): void { - const marker = this.markers.get(stage); - if (marker && !marker.stopped) { - marker.stop(); - } - } -} diff --git a/src/components/stages.tsx b/src/components/stages.tsx deleted file mode 100644 index 2586bece..00000000 --- a/src/components/stages.tsx +++ /dev/null @@ -1,607 +0,0 @@ -/* - * Copyright (c) 2024, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import { env } from 'node:process'; -import { ux } from '@oclif/core/ux'; -import { capitalCase } from 'change-case'; -import { Box, Instance, render, Text } from 'ink'; -import React from 'react'; - -import { SpinnerOrError, SpinnerOrErrorOrChildren } from './spinner.js'; -import { icons, spinners } from './design-elements.js'; -import { StageTracker } from './stage-tracker.js'; -import { msInMostReadableFormat, secondsInMostReadableFormat } from './utils.js'; -import { Divider } from './divider.js'; -import { Timer } from './timer.js'; - -// Taken from https://github.com/sindresorhus/is-in-ci -const isInCi = - env.CI !== '0' && - env.CI !== 'false' && - ('CI' in env || 'CONTINUOUS_INTEGRATION' in env || Object.keys(env).some((key) => key.startsWith('CI_'))); - -type Info> = { - /** - * key-value: Display a key-value pair with a spinner. - * static-key-value: Display a key-value pair without a spinner. - * message: Display a message. - */ - type: 'dynamic-key-value' | 'static-key-value' | 'message'; - /** - * Color of the value. - */ - color?: string; - /** - * Get the value to display. Takes the data property on the MultiStageComponent as an argument. - * Useful if you want to apply some logic (like rendering a link) to the data before displaying it. - * - * @param data The data property on the MultiStageComponent. - * @returns {string | undefined} - */ - get: (data?: T) => string | undefined; - /** - * Whether the value should be bold. - */ - bold?: boolean; -}; - -type KeyValuePair> = Info & { - /** - * Label of the key-value pair. - */ - label: string; - type: 'dynamic-key-value' | 'static-key-value'; -}; - -type SimpleMessage> = Info & { - type: 'message'; -}; - -type InfoBlock> = Array | SimpleMessage>; -type StageInfoBlock> = Array< - (KeyValuePair & { stage: string }) | (SimpleMessage & { stage: string }) ->; - -type FormattedKeyValue = { - readonly color?: string; - readonly isBold?: boolean; - // eslint-disable-next-line react/no-unused-prop-types - readonly label?: string; - readonly value: string | undefined; - // eslint-disable-next-line react/no-unused-prop-types - readonly stage?: string; - // eslint-disable-next-line react/no-unused-prop-types - readonly type: 'dynamic-key-value' | 'static-key-value' | 'message'; -}; - -type MultiStageComponentOptions> = { - /** - * Stages to render. - */ - readonly stages: readonly string[] | string[]; - /** - * Title to display at the top of the stages component. - */ - readonly title: string; - /** - * Information to display at the bottom of the stages component. - */ - readonly postStagesBlock?: Array | SimpleMessage>; - /** - * Information to display below the title but above the stages. - */ - readonly preStagesBlock?: Array | SimpleMessage>; - /** - * Whether to show the total elapsed time. Defaults to true - */ - readonly showElapsedTime?: boolean; - /** - * Whether to show the time spent on each stage. Defaults to true - */ - readonly showStageTime?: boolean; - /** - * Information to display for a specific stage. Each object must have a stage property set. - */ - readonly stageSpecificBlock?: StageInfoBlock; - /** - * The unit to use for the timer. Defaults to 'ms' - */ - readonly timerUnit?: 'ms' | 's'; - /** - * Data to display in the stages component. This data will be passed to the get function in the info object. - */ - readonly data?: Partial; - /** - * Whether JSON output is enabled. Defaults to false. - * - * Pass in this.jsonEnabled() from the command class to determine if JSON output is enabled. - */ - readonly jsonEnabled: boolean; -}; - -type StagesProps = { - readonly error?: Error | undefined; - readonly postStagesBlock?: FormattedKeyValue[]; - readonly preStagesBlock?: FormattedKeyValue[]; - readonly stageSpecificBlock?: FormattedKeyValue[]; - readonly title: string; - readonly hasElapsedTime?: boolean; - readonly hasStageTime?: boolean; - readonly timerUnit?: 'ms' | 's'; - readonly stageTracker: StageTracker; -}; - -function StaticKeyValue({ label, value, isBold, color }: FormattedKeyValue): React.ReactNode { - if (!value) return; - return ( - - {label}: - {value} - - ); -} - -function SimpleMessage({ value, color, isBold }: FormattedKeyValue): React.ReactNode { - if (!value) return; - return ( - - {value} - - ); -} - -function Infos({ - keyValuePairs, - error, - stage, -}: { - keyValuePairs: FormattedKeyValue[]; - error?: Error; - stage?: string; -}): React.ReactNode { - return ( - keyValuePairs - // If stage is provided, only show info for that stage - // otherwise, show all infos that don't have a specified stage - .filter((kv) => (stage ? kv.stage === stage : !kv.stage)) - .map((kv) => { - const key = `${kv.label}-${kv.value}`; - if (kv.type === 'message') { - return ; - } - - if (kv.type === 'dynamic-key-value') { - return ( - - {kv.value && ( - - {kv.value} - - )} - - ); - } - - if (kv.type === 'static-key-value') { - return ; - } - }) - ); -} - -function Stages({ - error, - hasElapsedTime = true, - hasStageTime = true, - postStagesBlock, - preStagesBlock, - stageSpecificBlock, - stageTracker, - timerUnit = 'ms', - title, -}: StagesProps): React.ReactNode { - return ( - - - - {preStagesBlock && preStagesBlock.length > 0 && ( - - - - )} - - - {[...stageTracker.entries()].map(([stage, status]) => ( - - - {(status === 'current' || status === 'failed') && ( - - )} - - {status === 'skipped' && ( - - {icons.skipped} {capitalCase(stage)} - Skipped - - )} - - {status === 'completed' && ( - - {icons.completed} - {capitalCase(stage)} - - )} - - {status === 'pending' && ( - - {icons.pending} {capitalCase(stage)} - - )} - {status !== 'pending' && status !== 'skipped' && hasStageTime && ( - - - - - )} - - - {stageSpecificBlock && stageSpecificBlock.length > 0 && status !== 'pending' && status !== 'skipped' && ( - - - - )} - - ))} - - - {postStagesBlock && postStagesBlock.length > 0 && ( - - - - )} - - {hasElapsedTime && ( - - Elapsed Time: - - - )} - - ); -} - -class CIMultiStageComponent> { - private seenStages: Set = new Set(); - private data?: Partial; - private startTime: number | undefined; - private startTimes: Map = new Map(); - private lastUpdateTime: number; - - private readonly postStagesBlock?: InfoBlock; - private readonly preStagesBlock?: InfoBlock; - private readonly stageSpecificBlock?: StageInfoBlock; - private readonly stages: readonly string[] | string[]; - private readonly title: string; - private readonly hasElapsedTime?: boolean; - private readonly hasStageTime?: boolean; - private readonly timerUnit?: 'ms' | 's'; - private readonly messageTimeout = parseInt(env.SF_CI_MESSAGE_TIMEOUT ?? '5000', 10) ?? 5000; - - public constructor({ - data, - postStagesBlock, - preStagesBlock, - showElapsedTime, - showStageTime, - stageSpecificBlock, - stages, - timerUnit, - title, - }: MultiStageComponentOptions) { - this.title = title; - this.stages = stages; - this.postStagesBlock = postStagesBlock; - this.preStagesBlock = preStagesBlock; - this.hasElapsedTime = showElapsedTime ?? true; - this.hasStageTime = showStageTime ?? true; - this.stageSpecificBlock = stageSpecificBlock; - this.timerUnit = timerUnit ?? 'ms'; - this.data = data; - this.lastUpdateTime = Date.now(); - - ux.stdout(`───── ${this.title} ─────`); - ux.stdout('Steps:'); - for (const stage of this.stages) { - ux.stdout(`${this.stages.indexOf(stage) + 1}. ${capitalCase(stage)}`); - } - ux.stdout(); - - if (this.hasElapsedTime) { - this.startTime = Date.now(); - } - } - - public update(stageTracker: StageTracker, data?: Partial): void { - this.data = { ...this.data, ...data } as T; - - for (const [stage, status] of stageTracker.entries()) { - // no need to re-render completed, failed, or skipped stages - if (this.seenStages.has(stage)) continue; - - switch (status) { - case 'pending': - // do nothing - break; - case 'current': - if (Date.now() - this.lastUpdateTime < this.messageTimeout) break; - this.lastUpdateTime = Date.now(); - if (!this.startTimes.has(stage)) this.startTimes.set(stage, Date.now()); - ux.stdout(`${icons.current} ${capitalCase(stage)}...`); - this.printInfo(this.preStagesBlock, 3); - this.printInfo( - this.stageSpecificBlock?.filter((info) => info.stage === stage), - 3 - ); - this.printInfo(this.postStagesBlock, 3); - break; - case 'failed': - case 'skipped': - case 'completed': - this.seenStages.add(stage); - if (this.hasStageTime && status !== 'skipped') { - const startTime = this.startTimes.get(stage); - const elapsedTime = startTime ? Date.now() - startTime : 0; - const displayTime = - this.timerUnit === 'ms' - ? msInMostReadableFormat(elapsedTime) - : secondsInMostReadableFormat(elapsedTime, 0); - ux.stdout(`${icons[status]} ${capitalCase(stage)} (${displayTime})`); - this.printInfo(this.preStagesBlock, 3); - this.printInfo( - this.stageSpecificBlock?.filter((info) => info.stage === stage), - 3 - ); - this.printInfo(this.postStagesBlock, 3); - } else if (status === 'skipped') { - ux.stdout(`${icons[status]} ${capitalCase(stage)} - Skipped`); - } else { - ux.stdout(`${icons[status]} ${capitalCase(stage)}`); - this.printInfo(this.preStagesBlock, 3); - this.printInfo( - this.stageSpecificBlock?.filter((info) => info.stage === stage), - 3 - ); - this.printInfo(this.postStagesBlock, 3); - } - - break; - default: - // do nothing - } - } - } - - public stop(stageTracker: StageTracker): void { - this.update(stageTracker); - if (this.startTime) { - const elapsedTime = Date.now() - this.startTime; - ux.stdout(); - const displayTime = - this.timerUnit === 'ms' ? msInMostReadableFormat(elapsedTime) : secondsInMostReadableFormat(elapsedTime, 0); - ux.stdout(`Elapsed time: ${displayTime}`); - ux.stdout(); - } - - this.printInfo(this.preStagesBlock); - this.printInfo(this.postStagesBlock); - } - - private printInfo(infoBlock: InfoBlock | StageInfoBlock | undefined, indent = 0): void { - const spaces = ' '.repeat(indent); - if (infoBlock?.length) { - for (const info of infoBlock) { - const formattedData = info.get ? info.get(this.data as T) : undefined; - if (!formattedData) continue; - if (info.type === 'message') { - ux.stdout(`${spaces}${formattedData}`); - } else { - ux.stdout(`${spaces}${info.label}: ${formattedData}`); - } - } - } - } -} - -export class MultiStageComponent> implements Disposable { - private data?: Partial; - private inkInstance: Instance | undefined; - private ciInstance: CIMultiStageComponent | undefined; - private stageTracker: StageTracker; - private stopped = false; - - private readonly postStagesBlock?: InfoBlock; - private readonly preStagesBlock?: InfoBlock; - private readonly stageSpecificBlock?: StageInfoBlock; - private readonly stages: readonly string[] | string[]; - private readonly title: string; - private readonly hasElapsedTime?: boolean; - private readonly hasStageTime?: boolean; - private readonly timerUnit?: 'ms' | 's'; - - public constructor({ - data, - jsonEnabled, - postStagesBlock, - preStagesBlock, - showElapsedTime, - showStageTime, - stageSpecificBlock, - stages, - timerUnit, - title, - }: MultiStageComponentOptions) { - this.data = data; - this.stages = stages; - this.title = title; - this.postStagesBlock = postStagesBlock; - this.preStagesBlock = preStagesBlock; - this.hasElapsedTime = showElapsedTime ?? true; - this.hasStageTime = showStageTime ?? true; - this.timerUnit = timerUnit ?? 'ms'; - this.stageTracker = new StageTracker(stages); - this.stageSpecificBlock = stageSpecificBlock; - - if (jsonEnabled) return; - - if (isInCi) { - this.ciInstance = new CIMultiStageComponent({ - stages, - title, - postStagesBlock, - preStagesBlock, - showElapsedTime, - showStageTime, - stageSpecificBlock, - timerUnit, - data, - jsonEnabled, - }); - } else { - this.inkInstance = render( - - ); - } - } - - public next(data?: Partial): void { - if (this.stopped) return; - - const nextStageIndex = this.stages.indexOf(this.stageTracker.current ?? this.stages[0]) + 1; - if (nextStageIndex < this.stages.length) { - this.update(this.stages[nextStageIndex], data); - } - } - - public goto(stage: string, data?: Partial): void { - if (this.stopped) return; - - // ignore non-existent stages - if (!this.stages.includes(stage)) return; - - // prevent going to a previous stage - if (this.stages.indexOf(stage) < this.stages.indexOf(this.stageTracker.current ?? this.stages[0])) return; - - this.update(stage, data); - } - - public updateData(data: Partial): void { - if (this.stopped) return; - this.data = { ...this.data, ...data } as T; - - this.update(this.stageTracker.current ?? this.stages[0], data); - } - - public stop(error?: Error): void { - if (this.stopped) return; - this.stopped = true; - - this.stageTracker.refresh(this.stageTracker.current ?? this.stages[0], { hasError: !!error, isStopping: true }); - - if (isInCi) { - this.ciInstance?.stop(this.stageTracker); - return; - } - - if (error) { - this.inkInstance?.rerender( - - ); - } else { - this.inkInstance?.rerender( - - ); - } - - this.inkInstance?.unmount(); - } - - public [Symbol.dispose](): void { - this.inkInstance?.unmount(); - } - - private update(stage: string, data?: Partial): void { - this.data = { ...this.data, ...data } as Partial; - - this.stageTracker.refresh(stage); - - if (isInCi) { - this.ciInstance?.update(this.stageTracker, this.data); - } else { - this.inkInstance?.rerender( - - ); - } - } - - private formatKeyValuePairs(infoBlock: InfoBlock | StageInfoBlock | undefined): FormattedKeyValue[] { - return ( - infoBlock?.map((info) => { - const formattedData = info.get ? info.get(this.data as T) : undefined; - return { - value: formattedData, - isBold: info.bold, - color: info.color, - type: info.type, - ...(info.type !== 'message' ? { label: info.label } : {}), - ...('stage' in info ? { stage: info.stage } : {}), - }; - }) ?? [] - ); - } -} diff --git a/src/components/timer.tsx b/src/components/timer.tsx deleted file mode 100644 index 64225113..00000000 --- a/src/components/timer.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2024, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import { Text } from 'ink'; -import React from 'react'; -import { msInMostReadableFormat, secondsInMostReadableFormat } from './utils.js'; - -export function Timer({ - color, - isStopped, - unit, -}: { - readonly color?: string; - readonly isStopped?: boolean; - readonly unit: 'ms' | 's'; -}): React.ReactNode { - const [time, setTime] = React.useState(0); - const [previousDate, setPreviousDate] = React.useState(Date.now()); - - React.useEffect(() => { - if (isStopped) { - setTime(time + (Date.now() - previousDate)); - setPreviousDate(Date.now()); - return () => {}; - } - - const intervalId = setInterval( - () => { - setTime(time + (Date.now() - previousDate)); - setPreviousDate(Date.now()); - }, - unit === 'ms' ? 1 : 1000 - ); - - return (): void => { - clearInterval(intervalId); - }; - }, [time, isStopped, previousDate, unit]); - - return ( - {unit === 'ms' ? msInMostReadableFormat(time) : secondsInMostReadableFormat(time, 0)} - ); -} diff --git a/src/components/utils.ts b/src/components/utils.ts deleted file mode 100644 index c8b4c7a5..00000000 --- a/src/components/utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2024, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -export function round(value: number, decimals = 2): string { - const factor = Math.pow(10, decimals); - return (Math.round(value * factor) / factor).toFixed(decimals); -} - -export function msInMostReadableFormat(time: number, decimals = 2): string { - // if time < 1000ms, return time in ms - if (time < 1000) { - return `${time}ms`; - } - - return secondsInMostReadableFormat(time, decimals); -} - -export function secondsInMostReadableFormat(time: number, decimals = 2): string { - if (time < 1000) { - return '< 1s'; - } - - // if time < 60s, return time in seconds - if (time < 60_000) { - return `${round(time / 1000, decimals)}s`; - } - - // if time < 60m, return time in minutes and seconds - if (time < 3_600_000) { - const minutes = Math.floor(time / 60_000); - const seconds = round((time % 60_000) / 1000, 0); - return `${minutes}m ${seconds}s`; - } - - return time.toString(); -} diff --git a/yarn.lock b/yarn.lock index 28ae9e4f..a1b8bfd1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1696,6 +1696,18 @@ wordwrap "^1.0.0" wrap-ansi "^7.0.0" +"@oclif/multi-stage-output@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@oclif/multi-stage-output/-/multi-stage-output-0.1.2.tgz#06977cde8888fce916d5ffc2ec8aeac92eb2ff5a" + integrity sha512-U9wWtg9YeFFNg1HsT7TUh23e9+OGdexzsP5EF2qNNwFkzIh7vY9jqpC5//XnYftxqqYpxJ+ehRC4yPaFMeZZJQ== + dependencies: + "@oclif/core" "^4" + change-case "^5.4.4" + cli-spinners "^2" + figures "^6.1.0" + ink "^5.0.1" + react "^18.3.1" + "@oclif/plugin-command-snapshot@^5.2.5": version "5.2.5" resolved "https://registry.yarnpkg.com/@oclif/plugin-command-snapshot/-/plugin-command-snapshot-5.2.5.tgz#c1a473d1650fba73e6c0bcf53f52cabcaa4794e4" From f95b9d5a5298cf38a10de200a1cd0c40d154fb9e Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Thu, 8 Aug 2024 10:16:24 -0600 Subject: [PATCH 05/15] chore: clean up --- messages/retrieve.start.md | 12 ------- src/commands/project/deploy/start.ts | 49 ++++++++++++++++++-------- src/commands/project/retrieve/start.ts | 13 +++---- 3 files changed, 41 insertions(+), 33 deletions(-) diff --git a/messages/retrieve.start.md b/messages/retrieve.start.md index da04593f..7dbe5d3b 100644 --- a/messages/retrieve.start.md +++ b/messages/retrieve.start.md @@ -141,18 +141,6 @@ Extract all files from the retrieved zip file. File name to use for the retrieved zip file. -# spinner.start - -Preparing retrieve request - -# spinner.sending - -Sending request to org - -# spinner.polling - -Waiting for the org to respond - # error.Conflicts There are changes in your local files that conflict with the org changes you're trying to retrieve. diff --git a/src/commands/project/deploy/start.ts b/src/commands/project/deploy/start.ts index 8f977f93..83146a12 100644 --- a/src/commands/project/deploy/start.ts +++ b/src/commands/project/deploy/start.ts @@ -7,7 +7,7 @@ import { MultiStageOutput } from '@oclif/multi-stage-output'; import { EnvironmentVariable, Lifecycle, Messages, OrgConfigProperties, SfError } from '@salesforce/core'; -import { DeployVersionData, MetadataApiDeployStatus } from '@salesforce/source-deploy-retrieve'; +import { DeployVersionData, MetadataApiDeployStatus, RequestStatus } from '@salesforce/source-deploy-retrieve'; import { Duration } from '@salesforce/kit'; import { SfCommand, toHelpSection, Flags } from '@salesforce/sf-plugins-core'; import { SourceConflictError, SourceMemberPollingEvent } from '@salesforce/source-tracking'; @@ -37,6 +37,10 @@ function round(value: number, precision: number): number { return Math.round(value * multiplier) / multiplier; } +function formatProgress(current: number, total: number): string { + return `${current}/${total} (${round((current / total) * 100, 0)}%)`; +} + export default class DeployMetadata extends SfCommand { public static readonly description = messages.getMessage('description'); public static readonly summary = messages.getMessage('summary'); @@ -212,7 +216,14 @@ export default class DeployMetadata extends SfCommand { targetOrg: string; }>({ title, - stages: ['Preparing', 'Deploying Metadata', 'Running Tests', 'Updating Source Tracking', 'Done'], + stages: [ + 'Preparing', + 'Waiting for the org to respond', + 'Deploying Metadata', + 'Running Tests', + 'Updating Source Tracking', + 'Done', + ], jsonEnabled: this.jsonEnabled(), preStagesBlock: [ { @@ -233,7 +244,7 @@ export default class DeployMetadata extends SfCommand { postStagesBlock: [ { label: 'Status', - get: (data) => data?.mdapiDeploy && mdTransferMessages.getMessage(data?.mdapiDeploy?.status), + get: (data) => data?.status, bold: true, type: 'dynamic-key-value', }, @@ -253,10 +264,10 @@ export default class DeployMetadata extends SfCommand { label: 'Components', get: (data) => data?.mdapiDeploy?.numberComponentsTotal - ? `${data?.mdapiDeploy?.numberComponentsDeployed}/${data?.mdapiDeploy?.numberComponentsTotal} (${round( - (data?.mdapiDeploy?.numberComponentsDeployed / data?.mdapiDeploy?.numberComponentsTotal) * 100, - 0 - )}%)` + ? formatProgress( + data?.mdapiDeploy?.numberComponentsDeployed ?? 0, + data?.mdapiDeploy?.numberComponentsTotal + ) : undefined, stage: 'Deploying Metadata', type: 'dynamic-key-value', @@ -265,9 +276,7 @@ export default class DeployMetadata extends SfCommand { label: 'Tests', get: (data) => data?.mdapiDeploy?.numberTestsTotal && data?.mdapiDeploy?.numberTestsCompleted - ? `${data?.mdapiDeploy?.numberTestsCompleted ?? 0}/${data?.mdapiDeploy?.numberTestsTotal ?? 0} ${ - data?.mdapiDeploy?.numberTestErrors ? `(Errors: ${data?.mdapiDeploy?.numberTestErrors})` : '' - }` + ? formatProgress(data?.mdapiDeploy?.numberTestsCompleted, data?.mdapiDeploy?.numberTestsTotal) : undefined, stage: 'Running Tests', type: 'dynamic-key-value', @@ -276,9 +285,10 @@ export default class DeployMetadata extends SfCommand { label: 'Members', get: (data) => data?.sourceMemberPolling && - `${data.sourceMemberPolling.original - data.sourceMemberPolling.remaining}/${ + formatProgress( + data.sourceMemberPolling.original - data.sourceMemberPolling.remaining, data.sourceMemberPolling.original - }`, + ), stage: 'Updating Source Tracking', type: 'dynamic-key-value', }, @@ -308,6 +318,9 @@ export default class DeployMetadata extends SfCommand { } if (flags.async) { + ms.goto('Done', { status: 'Queued', targetOrg: username }); + ms.stop(); + this.log(); if (flags['coverage-formatters']) { this.warn(messages.getMessage('asyncCoverageJunitWarning')); } @@ -324,14 +337,20 @@ export default class DeployMetadata extends SfCommand { ); deploy.onUpdate((data) => { + // if (!this.jsonEnabled()) console.log(data); if ( data.numberComponentsDeployed === data.numberComponentsTotal && data.numberTestsTotal > 0 && data.numberComponentsDeployed > 0 ) { - ms.goto('Running Tests', { mdapiDeploy: data }); + ms.goto('Running Tests', { mdapiDeploy: data, status: mdTransferMessages.getMessage(data?.status) }); + } else if (data.status === RequestStatus.Pending) { + ms.goto('Waiting for the org to respond', { + mdapiDeploy: data, + status: mdTransferMessages.getMessage(data?.status), + }); } else { - ms.goto('Deploying Metadata', { mdapiDeploy: data }); + ms.goto('Deploying Metadata', { mdapiDeploy: data, status: mdTransferMessages.getMessage(data?.status) }); } }); @@ -343,7 +362,7 @@ export default class DeployMetadata extends SfCommand { deploy.onCancel(() => ms.stop()); deploy.onError((error: Error) => { - ms.stop(); + ms.stop(error); throw error; }); diff --git a/src/commands/project/retrieve/start.ts b/src/commands/project/retrieve/start.ts index 139bb591..a91d1cf1 100644 --- a/src/commands/project/retrieve/start.ts +++ b/src/commands/project/retrieve/start.ts @@ -192,7 +192,7 @@ export default class RetrieveMetadata extends SfCommand { ], }); - ms.goto(messages.getMessage('spinner.start')); + ms.goto('Preparing retrieve request'); const { componentSetFromNonDeletes, fileResponsesFromDelete = [] } = await buildRetrieveAndDeleteTargets( flags, @@ -210,7 +210,7 @@ export default class RetrieveMetadata extends SfCommand { } const retrieveOpts = await buildRetrieveOptions(flags, format, zipFileName, resolvedTargetDir); - ms.goto(messages.getMessage('spinner.sending')); + ms.goto('Sending request to org'); this.retrieveResult = new RetrieveResult({} as MetadataApiRetrieveStatus, componentSetFromNonDeletes); @@ -219,13 +219,14 @@ export default class RetrieveMetadata extends SfCommand { Promise.resolve(ms.updateData({ apiData })) ); const retrieve = await componentSetFromNonDeletes.retrieve(retrieveOpts); - ms.goto(messages.getMessage('spinner.polling'), { status: 'Pending' }); + ms.goto('Waiting for the org to respond', { status: 'Pending' }); retrieve.onUpdate((data) => { - ms.goto(messages.getMessage('spinner.polling'), { status: mdTransferMessages.getMessage(data.status) }); + ms.goto('Waiting for the org to respond', { status: mdTransferMessages.getMessage(data.status) }); + }); + retrieve.onFinish((data) => { + ms.goto('Done', { status: mdTransferMessages.getMessage(data.response.status) }); }); - // any thing else should stop the progress bar - retrieve.onFinish((data) => ms.goto('Done', { status: mdTransferMessages.getMessage(data.response.status) })); retrieve.onCancel((data) => ms.goto('Done', { status: mdTransferMessages.getMessage(data?.status ?? 'Canceled') }) ); From 85d77deeb018d60b348ec4b2ae38d7c9d852257a Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Thu, 8 Aug 2024 10:46:05 -0600 Subject: [PATCH 06/15] feat: use @oclif/multi-stage-output --- src/commands/project/deploy/start.ts | 12 +++++++++--- src/commands/project/retrieve/start.ts | 11 ++++++++--- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/commands/project/deploy/start.ts b/src/commands/project/deploy/start.ts index 83146a12..493fb160 100644 --- a/src/commands/project/deploy/start.ts +++ b/src/commands/project/deploy/start.ts @@ -320,7 +320,6 @@ export default class DeployMetadata extends SfCommand { if (flags.async) { ms.goto('Done', { status: 'Queued', targetOrg: username }); ms.stop(); - this.log(); if (flags['coverage-formatters']) { this.warn(messages.getMessage('asyncCoverageJunitWarning')); } @@ -337,7 +336,6 @@ export default class DeployMetadata extends SfCommand { ); deploy.onUpdate((data) => { - // if (!this.jsonEnabled()) console.log(data); if ( data.numberComponentsDeployed === data.numberComponentsTotal && data.numberTestsTotal > 0 && @@ -359,9 +357,17 @@ export default class DeployMetadata extends SfCommand { ms.stop(); }); - deploy.onCancel(() => ms.stop()); + deploy.onCancel((data) => { + ms.updateData({ mdapiDeploy: data, status: mdTransferMessages.getMessage(data?.status ?? 'Canceled') }); + + ms.stop(new Error('Deploy canceled')); + }); deploy.onError((error: Error) => { + if (error.message.includes('client has timed out')) { + ms.updateData({ status: 'Client Timeout' }); + } + ms.stop(error); throw error; }); diff --git a/src/commands/project/retrieve/start.ts b/src/commands/project/retrieve/start.ts index a91d1cf1..50665ccc 100644 --- a/src/commands/project/retrieve/start.ts +++ b/src/commands/project/retrieve/start.ts @@ -227,10 +227,15 @@ export default class RetrieveMetadata extends SfCommand { retrieve.onFinish((data) => { ms.goto('Done', { status: mdTransferMessages.getMessage(data.response.status) }); }); - retrieve.onCancel((data) => - ms.goto('Done', { status: mdTransferMessages.getMessage(data?.status ?? 'Canceled') }) - ); + retrieve.onCancel((data) => { + ms.updateData({ status: mdTransferMessages.getMessage(data?.status ?? 'Canceled') }); + ms.stop(new Error('Retrieve canceled')); + }); retrieve.onError((error: Error) => { + if (error.message.includes('client has timed out')) { + ms.updateData({ status: 'Client Timeout' }); + } + ms.stop(error); throw error; }); From 0b94c817ecb8cdc694b300285da6efcd0348ed91 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Thu, 8 Aug 2024 10:48:09 -0600 Subject: [PATCH 07/15] chore: bump multi-stage-output --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 936404d0..b823c8bc 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "bugs": "https://github.com/forcedotcom/cli/issues", "dependencies": { "@oclif/core": "^4.0.12", - "@oclif/multi-stage-output": "^0.1.2", + "@oclif/multi-stage-output": "^0.1.4", "@salesforce/apex-node": "^8.1.1", "@salesforce/core": "^8.2.8", "@salesforce/kit": "^3.1.6", diff --git a/yarn.lock b/yarn.lock index 3f8e5921..c47bb683 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1242,10 +1242,10 @@ wordwrap "^1.0.0" wrap-ansi "^7.0.0" -"@oclif/multi-stage-output@^0.1.2": - version "0.1.2" - resolved "https://registry.yarnpkg.com/@oclif/multi-stage-output/-/multi-stage-output-0.1.2.tgz#06977cde8888fce916d5ffc2ec8aeac92eb2ff5a" - integrity sha512-U9wWtg9YeFFNg1HsT7TUh23e9+OGdexzsP5EF2qNNwFkzIh7vY9jqpC5//XnYftxqqYpxJ+ehRC4yPaFMeZZJQ== +"@oclif/multi-stage-output@^0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@oclif/multi-stage-output/-/multi-stage-output-0.1.4.tgz#c36e6addadbe5796f3a9ba8c6a1515f2bcd72166" + integrity sha512-fZQHKQjC2wyLjTkyNra2rcAN4p2m7WSTi5kubY7SrLVrckC8oQO2ia/Wq1kafscmEhhTwC3Pn0CgGidpXU2Azw== dependencies: "@oclif/core" "^4" change-case "^5.4.4" From bb63b2e1a8758683221c31e5fd79cfee0aaf2d34 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Thu, 8 Aug 2024 12:48:39 -0600 Subject: [PATCH 08/15] fix: handle source conflicts --- src/commands/project/deploy/start.ts | 44 ++++++++++++++++---------- src/commands/project/retrieve/start.ts | 33 ++++++++++++------- 2 files changed, 49 insertions(+), 28 deletions(-) diff --git a/src/commands/project/deploy/start.ts b/src/commands/project/deploy/start.ts index 493fb160..8cd4e077 100644 --- a/src/commands/project/deploy/start.ts +++ b/src/commands/project/deploy/start.ts @@ -185,6 +185,14 @@ export default class DeployMetadata extends SfCommand { public static errorCodes = toHelpSection('ERROR CODES', DEPLOY_STATUS_CODES_DESCRIPTIONS); + protected ms!: MultiStageOutput<{ + mdapiDeploy: MetadataApiDeployStatus; + sourceMemberPolling: SourceMemberPollingEvent; + status: string; + apiData: DeployVersionData; + targetOrg: string; + }>; + public async run(): Promise { const { flags } = await this.parse(DeployMetadata); const project = await getOptionalProject(); @@ -208,7 +216,7 @@ export default class DeployMetadata extends SfCommand { const username = flags['target-org'].getUsername(); const title = flags['dry-run'] ? 'Deploying Metadata (dry-run)' : 'Deploying Metadata'; - const ms = new MultiStageOutput<{ + this.ms = new MultiStageOutput<{ mdapiDeploy: MetadataApiDeployStatus; sourceMemberPolling: SourceMemberPollingEvent; status: string; @@ -296,7 +304,9 @@ export default class DeployMetadata extends SfCommand { }); const lifecycle = Lifecycle.getInstance(); - lifecycle.on('apiVersionDeploy', async (apiData: DeployVersionData) => Promise.resolve(ms.updateData({ apiData }))); + lifecycle.on('apiVersionDeploy', async (apiData: DeployVersionData) => + Promise.resolve(this.ms.updateData({ apiData })) + ); const { deploy } = await executeDeploy( { @@ -308,7 +318,7 @@ export default class DeployMetadata extends SfCommand { ); if (!deploy) { - ms.stop(); + this.ms.stop(); this.log('No changes to deploy'); return { status: 'Nothing to deploy', files: [] }; } @@ -318,8 +328,8 @@ export default class DeployMetadata extends SfCommand { } if (flags.async) { - ms.goto('Done', { status: 'Queued', targetOrg: username }); - ms.stop(); + this.ms.goto('Done', { status: 'Queued', targetOrg: username }); + this.ms.stop(); if (flags['coverage-formatters']) { this.warn(messages.getMessage('asyncCoverageJunitWarning')); } @@ -328,11 +338,11 @@ export default class DeployMetadata extends SfCommand { return asyncFormatter.getJson(); } - ms.goto('Preparing', { targetOrg: username }); + this.ms.goto('Preparing', { targetOrg: username }); // for sourceMember polling events lifecycle.on('sourceMemberPollingEvent', (event: SourceMemberPollingEvent) => - Promise.resolve(ms.goto('Updating Source Tracking', { sourceMemberPolling: event })) + Promise.resolve(this.ms.goto('Updating Source Tracking', { sourceMemberPolling: event })) ); deploy.onUpdate((data) => { @@ -341,34 +351,34 @@ export default class DeployMetadata extends SfCommand { data.numberTestsTotal > 0 && data.numberComponentsDeployed > 0 ) { - ms.goto('Running Tests', { mdapiDeploy: data, status: mdTransferMessages.getMessage(data?.status) }); + this.ms.goto('Running Tests', { mdapiDeploy: data, status: mdTransferMessages.getMessage(data?.status) }); } else if (data.status === RequestStatus.Pending) { - ms.goto('Waiting for the org to respond', { + this.ms.goto('Waiting for the org to respond', { mdapiDeploy: data, status: mdTransferMessages.getMessage(data?.status), }); } else { - ms.goto('Deploying Metadata', { mdapiDeploy: data, status: mdTransferMessages.getMessage(data?.status) }); + this.ms.goto('Deploying Metadata', { mdapiDeploy: data, status: mdTransferMessages.getMessage(data?.status) }); } }); deploy.onFinish((data) => { - ms.goto('Done', { mdapiDeploy: data.response, status: mdTransferMessages.getMessage(data.response.status) }); - ms.stop(); + this.ms.goto('Done', { mdapiDeploy: data.response, status: mdTransferMessages.getMessage(data.response.status) }); + this.ms.stop(); }); deploy.onCancel((data) => { - ms.updateData({ mdapiDeploy: data, status: mdTransferMessages.getMessage(data?.status ?? 'Canceled') }); + this.ms.updateData({ mdapiDeploy: data, status: mdTransferMessages.getMessage(data?.status ?? 'Canceled') }); - ms.stop(new Error('Deploy canceled')); + this.ms.stop(new Error('Deploy canceled')); }); deploy.onError((error: Error) => { if (error.message.includes('client has timed out')) { - ms.updateData({ status: 'Client Timeout' }); + this.ms.updateData({ status: 'Client Timeout' }); } - ms.stop(error); + this.ms.stop(error); throw error; }); @@ -389,6 +399,8 @@ export default class DeployMetadata extends SfCommand { protected catch(error: Error | SfError): Promise { if (error instanceof SourceConflictError && error.data) { if (!this.jsonEnabled()) { + this.ms.updateData({ status: 'Failed' }); + this.ms.stop(error); writeConflictTable(error.data); // set the message and add plugin-specific actions return super.catch({ diff --git a/src/commands/project/retrieve/start.ts b/src/commands/project/retrieve/start.ts index 50665ccc..5c28c476 100644 --- a/src/commands/project/retrieve/start.ts +++ b/src/commands/project/retrieve/start.ts @@ -147,6 +147,10 @@ export default class RetrieveMetadata extends SfCommand { ); protected retrieveResult!: RetrieveResult; + protected ms!: MultiStageOutput<{ + status: string; + apiData: RetrieveVersionData; + }>; // eslint-disable-next-line complexity public async run(): Promise { @@ -162,7 +166,7 @@ export default class RetrieveMetadata extends SfCommand { const zipFileName = flags['zip-file-name'] ?? DEFAULT_ZIP_FILE_NAME; const stages = ['Preparing retrieve request', 'Sending request to org', 'Waiting for the org to respond', 'Done']; - const ms = new MultiStageOutput<{ + this.ms = new MultiStageOutput<{ status: string; apiData: RetrieveVersionData; }>({ @@ -192,13 +196,14 @@ export default class RetrieveMetadata extends SfCommand { ], }); - ms.goto('Preparing retrieve request'); + this.ms.goto('Preparing retrieve request'); const { componentSetFromNonDeletes, fileResponsesFromDelete = [] } = await buildRetrieveAndDeleteTargets( flags, format ); if (format === 'source' && (Boolean(flags.manifest) || Boolean(flags.metadata))) { + // TODO: test this const access = new RegistryAccess(undefined, SfProject.getInstance()?.getPath()); if (wantsToRetrieveCustomFields(componentSetFromNonDeletes, access)) { this.warn(messages.getMessage('wantsToRetrieveCustomFields')); @@ -210,40 +215,40 @@ export default class RetrieveMetadata extends SfCommand { } const retrieveOpts = await buildRetrieveOptions(flags, format, zipFileName, resolvedTargetDir); - ms.goto('Sending request to org'); + this.ms.goto('Sending request to org'); this.retrieveResult = new RetrieveResult({} as MetadataApiRetrieveStatus, componentSetFromNonDeletes); if (componentSetFromNonDeletes.size !== 0 || retrieveOpts.packageOptions?.length) { Lifecycle.getInstance().on('apiVersionRetrieve', async (apiData: RetrieveVersionData) => - Promise.resolve(ms.updateData({ apiData })) + Promise.resolve(this.ms.updateData({ apiData })) ); const retrieve = await componentSetFromNonDeletes.retrieve(retrieveOpts); - ms.goto('Waiting for the org to respond', { status: 'Pending' }); + this.ms.goto('Waiting for the org to respond', { status: 'Pending' }); retrieve.onUpdate((data) => { - ms.goto('Waiting for the org to respond', { status: mdTransferMessages.getMessage(data.status) }); + this.ms.goto('Waiting for the org to respond', { status: mdTransferMessages.getMessage(data.status) }); }); retrieve.onFinish((data) => { - ms.goto('Done', { status: mdTransferMessages.getMessage(data.response.status) }); + this.ms.goto('Done', { status: mdTransferMessages.getMessage(data.response.status) }); }); retrieve.onCancel((data) => { - ms.updateData({ status: mdTransferMessages.getMessage(data?.status ?? 'Canceled') }); - ms.stop(new Error('Retrieve canceled')); + this.ms.updateData({ status: mdTransferMessages.getMessage(data?.status ?? 'Canceled') }); + this.ms.stop(new Error('Retrieve canceled')); }); retrieve.onError((error: Error) => { if (error.message.includes('client has timed out')) { - ms.updateData({ status: 'Client Timeout' }); + this.ms.updateData({ status: 'Client Timeout' }); } - ms.stop(error); + this.ms.stop(error); throw error; }); this.retrieveResult = await retrieve.pollStatus(500, flags.wait.seconds); } - ms.stop(); + this.ms.stop(); // flags['output-dir'] will set resolvedTargetDir var, so this check is redundant, but allows for nice typings in the moveResultsForRetrieveTargetDir method if (flags['output-dir'] && resolvedTargetDir) { @@ -294,6 +299,8 @@ export default class RetrieveMetadata extends SfCommand { protected catch(error: Error | SfError): Promise { if (!this.jsonEnabled() && error instanceof SourceConflictError && error.data) { + this.ms.updateData({ status: 'Failed' }); + this.ms.stop(error); writeConflictTable(error.data); // set the message and add plugin-specific actions return super.catch({ @@ -301,6 +308,8 @@ export default class RetrieveMetadata extends SfCommand { message: messages.getMessage('error.Conflicts'), actions: messages.getMessages('error.Conflicts.Actions'), }); + } else { + this.ms.stop(error); } return super.catch(error); From 270cf7a575d7c615e211b41fc34ec26c60eeafa8 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Thu, 8 Aug 2024 12:49:40 -0600 Subject: [PATCH 09/15] chore: remove comment --- src/commands/project/retrieve/start.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/commands/project/retrieve/start.ts b/src/commands/project/retrieve/start.ts index 5c28c476..142646f8 100644 --- a/src/commands/project/retrieve/start.ts +++ b/src/commands/project/retrieve/start.ts @@ -203,7 +203,6 @@ export default class RetrieveMetadata extends SfCommand { format ); if (format === 'source' && (Boolean(flags.manifest) || Boolean(flags.metadata))) { - // TODO: test this const access = new RegistryAccess(undefined, SfProject.getInstance()?.getPath()); if (wantsToRetrieveCustomFields(componentSetFromNonDeletes, access)) { this.warn(messages.getMessage('wantsToRetrieveCustomFields')); From 360c2c782ea92d3a6f6aa0a41fe51da734a1d2ac Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Thu, 8 Aug 2024 12:56:49 -0600 Subject: [PATCH 10/15] fix: move CustomField warning --- src/commands/project/retrieve/start.ts | 29 +++++++++++++------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/commands/project/retrieve/start.ts b/src/commands/project/retrieve/start.ts index 142646f8..1d5e51c2 100644 --- a/src/commands/project/retrieve/start.ts +++ b/src/commands/project/retrieve/start.ts @@ -165,6 +165,21 @@ export default class RetrieveMetadata extends SfCommand { const format = flags['target-metadata-dir'] ? 'metadata' : 'source'; const zipFileName = flags['zip-file-name'] ?? DEFAULT_ZIP_FILE_NAME; + const { componentSetFromNonDeletes, fileResponsesFromDelete = [] } = await buildRetrieveAndDeleteTargets( + flags, + format + ); + if (format === 'source' && (Boolean(flags.manifest) || Boolean(flags.metadata))) { + const access = new RegistryAccess(undefined, SfProject.getInstance()?.getPath()); + if (wantsToRetrieveCustomFields(componentSetFromNonDeletes, access)) { + this.warn(messages.getMessage('wantsToRetrieveCustomFields')); + componentSetFromNonDeletes.add({ + fullName: ComponentSet.WILDCARD, + type: access.getTypeByName('CustomObject'), + }); + } + } + const stages = ['Preparing retrieve request', 'Sending request to org', 'Waiting for the org to respond', 'Done']; this.ms = new MultiStageOutput<{ status: string; @@ -198,20 +213,6 @@ export default class RetrieveMetadata extends SfCommand { this.ms.goto('Preparing retrieve request'); - const { componentSetFromNonDeletes, fileResponsesFromDelete = [] } = await buildRetrieveAndDeleteTargets( - flags, - format - ); - if (format === 'source' && (Boolean(flags.manifest) || Boolean(flags.metadata))) { - const access = new RegistryAccess(undefined, SfProject.getInstance()?.getPath()); - if (wantsToRetrieveCustomFields(componentSetFromNonDeletes, access)) { - this.warn(messages.getMessage('wantsToRetrieveCustomFields')); - componentSetFromNonDeletes.add({ - fullName: ComponentSet.WILDCARD, - type: access.getTypeByName('CustomObject'), - }); - } - } const retrieveOpts = await buildRetrieveOptions(flags, format, zipFileName, resolvedTargetDir); this.ms.goto('Sending request to org'); From 090fbe1bc9cca7feaeba702aa56352e8af90ef87 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Mon, 19 Aug 2024 11:16:07 -0600 Subject: [PATCH 11/15] chore: bump MultiStageOutput --- package.json | 2 +- yarn.lock | 27 +++++++++++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index b823c8bc..1daed685 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "bugs": "https://github.com/forcedotcom/cli/issues", "dependencies": { "@oclif/core": "^4.0.12", - "@oclif/multi-stage-output": "^0.1.4", + "@oclif/multi-stage-output": "^0.3.0", "@salesforce/apex-node": "^8.1.1", "@salesforce/core": "^8.2.8", "@salesforce/kit": "^3.1.6", diff --git a/yarn.lock b/yarn.lock index c47bb683..2ea88a82 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1242,12 +1242,13 @@ wordwrap "^1.0.0" wrap-ansi "^7.0.0" -"@oclif/multi-stage-output@^0.1.4": - version "0.1.4" - resolved "https://registry.yarnpkg.com/@oclif/multi-stage-output/-/multi-stage-output-0.1.4.tgz#c36e6addadbe5796f3a9ba8c6a1515f2bcd72166" - integrity sha512-fZQHKQjC2wyLjTkyNra2rcAN4p2m7WSTi5kubY7SrLVrckC8oQO2ia/Wq1kafscmEhhTwC3Pn0CgGidpXU2Azw== +"@oclif/multi-stage-output@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@oclif/multi-stage-output/-/multi-stage-output-0.3.0.tgz#2d74763bd2215c61ec1cd53faf3138328d50122d" + integrity sha512-7pMD3CoXfS9/hEWzZ1b8GZb3leSNWENsMJS7uYTX0XqTXlrzh+eYvFHYcJh9bEkIw67LKjFfGGIwXbotdhtZKg== dependencies: "@oclif/core" "^4" + "@types/react" "^18.3.3" change-case "^5.4.4" cli-spinners "^2" figures "^6.1.0" @@ -2288,6 +2289,19 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA== +"@types/prop-types@*": + version "15.7.12" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6" + integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q== + +"@types/react@^18.3.3": + version "18.3.3" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.3.tgz#9679020895318b0915d7a3ab004d92d33375c45f" + integrity sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw== + dependencies: + "@types/prop-types" "*" + csstype "^3.0.2" + "@types/responselike@^1.0.0": version "1.0.3" resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.3.tgz#cc29706f0a397cfe6df89debfe4bf5cea159db50" @@ -3394,6 +3408,11 @@ csprng@*: dependencies: sequin "*" +csstype@^3.0.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + csv-parse@^5.5.2: version "5.5.6" resolved "https://registry.yarnpkg.com/csv-parse/-/csv-parse-5.5.6.tgz#0d726d58a60416361358eec291a9f93abe0b6b1a" From f09be60d212faa525456f4984192b414bb0bd207 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Mon, 19 Aug 2024 15:01:34 -0600 Subject: [PATCH 12/15] feat: abstract out multi stage output --- src/commands/project/deploy/report.ts | 2 +- src/commands/project/deploy/start.ts | 169 ++++--------------------- src/utils/multiStageOutput.ts | 172 ++++++++++++++++++++++++++ 3 files changed, 198 insertions(+), 145 deletions(-) create mode 100644 src/utils/multiStageOutput.ts diff --git a/src/commands/project/deploy/report.ts b/src/commands/project/deploy/report.ts index 283205fc..d36c7eeb 100644 --- a/src/commands/project/deploy/report.ts +++ b/src/commands/project/deploy/report.ts @@ -70,7 +70,7 @@ export default class DeployMetadataReport extends SfCommand { const jobId = cache.resolveLatest(flags['use-most-recent'], flags['job-id'], false); const deployOpts = cache.maybeGet(jobId); - const wait = flags['wait']; + const { wait } = flags; const org = deployOpts?.['target-org'] ? await Org.create({ aliasOrUsername: deployOpts['target-org'] }) : flags['target-org']; diff --git a/src/commands/project/deploy/start.ts b/src/commands/project/deploy/start.ts index 8cd4e077..eedf4123 100644 --- a/src/commands/project/deploy/start.ts +++ b/src/commands/project/deploy/start.ts @@ -7,10 +7,11 @@ import { MultiStageOutput } from '@oclif/multi-stage-output'; import { EnvironmentVariable, Lifecycle, Messages, OrgConfigProperties, SfError } from '@salesforce/core'; -import { DeployVersionData, MetadataApiDeployStatus, RequestStatus } from '@salesforce/source-deploy-retrieve'; +import { DeployVersionData, MetadataApiDeployStatus } from '@salesforce/source-deploy-retrieve'; import { Duration } from '@salesforce/kit'; import { SfCommand, toHelpSection, Flags } from '@salesforce/sf-plugins-core'; import { SourceConflictError, SourceMemberPollingEvent } from '@salesforce/source-tracking'; +import { DeployStages } from '../../../utils/multiStageOutput.js'; import { AsyncDeployResultFormatter } from '../../../formatters/asyncDeployResultFormatter.js'; import { DeployResultFormatter } from '../../../formatters/deployResultFormatter.js'; import { DeployResultJson, TestLevel } from '../../../utils/types.js'; @@ -24,7 +25,6 @@ import { getOptionalProject } from '../../../utils/project.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-deploy-retrieve', 'deploy.metadata'); -const mdTransferMessages = Messages.loadMessages('@salesforce/plugin-deploy-retrieve', 'metadata.transfer'); const exclusiveFlags = ['manifest', 'source-dir', 'metadata', 'metadata-dir']; const mdapiFormatFlags = 'Metadata API Format'; @@ -32,15 +32,6 @@ const sourceFormatFlags = 'Source Format'; const testFlags = 'Test'; const destructiveFlags = 'Delete'; -function round(value: number, precision: number): number { - const multiplier = Math.pow(10, precision || 0); - return Math.round(value * multiplier) / multiplier; -} - -function formatProgress(current: number, total: number): string { - return `${current}/${total} (${round((current / total) * 100, 0)}%)`; -} - export default class DeployMetadata extends SfCommand { public static readonly description = messages.getMessage('description'); public static readonly summary = messages.getMessage('summary'); @@ -193,6 +184,8 @@ export default class DeployMetadata extends SfCommand { targetOrg: string; }>; + protected stages!: DeployStages; + public async run(): Promise { const { flags } = await this.parse(DeployMetadata); const project = await getOptionalProject(); @@ -216,96 +209,26 @@ export default class DeployMetadata extends SfCommand { const username = flags['target-org'].getUsername(); const title = flags['dry-run'] ? 'Deploying Metadata (dry-run)' : 'Deploying Metadata'; - this.ms = new MultiStageOutput<{ - mdapiDeploy: MetadataApiDeployStatus; - sourceMemberPolling: SourceMemberPollingEvent; - status: string; - apiData: DeployVersionData; - targetOrg: string; - }>({ + this.stages = new DeployStages({ title, - stages: [ - 'Preparing', - 'Waiting for the org to respond', - 'Deploying Metadata', - 'Running Tests', - 'Updating Source Tracking', - 'Done', - ], jsonEnabled: this.jsonEnabled(), - preStagesBlock: [ - { - type: 'message', - get: (data) => - data?.apiData && - messages.getMessage('apiVersionMsgDetailed', [ - flags['dry-run'] ? 'Deploying (dry-run)' : 'Deploying', - // technically manifestVersion can be undefined, but only on raw mdapi deployments. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - flags['metadata-dir'] ? '' : `v${data.apiData.manifestVersion}`, - username, - data.apiData.apiVersion, - data.apiData.webService, - ]), - }, - ], - postStagesBlock: [ - { - label: 'Status', - get: (data) => data?.status, - bold: true, - type: 'dynamic-key-value', - }, - { - label: 'Deploy ID', - get: (data) => data?.mdapiDeploy?.id, - type: 'static-key-value', - }, - { - label: 'Target Org', - get: (data) => data?.targetOrg, - type: 'static-key-value', - }, - ], - stageSpecificBlock: [ - { - label: 'Components', - get: (data) => - data?.mdapiDeploy?.numberComponentsTotal - ? formatProgress( - data?.mdapiDeploy?.numberComponentsDeployed ?? 0, - data?.mdapiDeploy?.numberComponentsTotal - ) - : undefined, - stage: 'Deploying Metadata', - type: 'dynamic-key-value', - }, - { - label: 'Tests', - get: (data) => - data?.mdapiDeploy?.numberTestsTotal && data?.mdapiDeploy?.numberTestsCompleted - ? formatProgress(data?.mdapiDeploy?.numberTestsCompleted, data?.mdapiDeploy?.numberTestsTotal) - : undefined, - stage: 'Running Tests', - type: 'dynamic-key-value', - }, - { - label: 'Members', - get: (data) => - data?.sourceMemberPolling && - formatProgress( - data.sourceMemberPolling.original - data.sourceMemberPolling.remaining, - data.sourceMemberPolling.original - ), - stage: 'Updating Source Tracking', - type: 'dynamic-key-value', - }, - ], }); const lifecycle = Lifecycle.getInstance(); lifecycle.on('apiVersionDeploy', async (apiData: DeployVersionData) => - Promise.resolve(this.ms.updateData({ apiData })) + Promise.resolve( + this.stages.update({ + apiMessage: messages.getMessage('apiVersionMsgDetailed', [ + flags['dry-run'] ? 'Deploying (dry-run)' : 'Deploying', + // technically manifestVersion can be undefined, but only on raw mdapi deployments. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + flags['metadata-dir'] ? '' : `v${apiData.manifestVersion}`, + username, + apiData.apiVersion, + apiData.webService, + ]), + }) + ) ); const { deploy } = await executeDeploy( @@ -317,8 +240,10 @@ export default class DeployMetadata extends SfCommand { project ); + this.stages.start(username, deploy); + if (!deploy) { - this.ms.stop(); + this.stages.stop(); this.log('No changes to deploy'); return { status: 'Nothing to deploy', files: [] }; } @@ -328,8 +253,8 @@ export default class DeployMetadata extends SfCommand { } if (flags.async) { - this.ms.goto('Done', { status: 'Queued', targetOrg: username }); - this.ms.stop(); + this.stages.done({ status: 'Queued', username }); + this.stages.stop(); if (flags['coverage-formatters']) { this.warn(messages.getMessage('asyncCoverageJunitWarning')); } @@ -338,50 +263,6 @@ export default class DeployMetadata extends SfCommand { return asyncFormatter.getJson(); } - this.ms.goto('Preparing', { targetOrg: username }); - - // for sourceMember polling events - lifecycle.on('sourceMemberPollingEvent', (event: SourceMemberPollingEvent) => - Promise.resolve(this.ms.goto('Updating Source Tracking', { sourceMemberPolling: event })) - ); - - deploy.onUpdate((data) => { - if ( - data.numberComponentsDeployed === data.numberComponentsTotal && - data.numberTestsTotal > 0 && - data.numberComponentsDeployed > 0 - ) { - this.ms.goto('Running Tests', { mdapiDeploy: data, status: mdTransferMessages.getMessage(data?.status) }); - } else if (data.status === RequestStatus.Pending) { - this.ms.goto('Waiting for the org to respond', { - mdapiDeploy: data, - status: mdTransferMessages.getMessage(data?.status), - }); - } else { - this.ms.goto('Deploying Metadata', { mdapiDeploy: data, status: mdTransferMessages.getMessage(data?.status) }); - } - }); - - deploy.onFinish((data) => { - this.ms.goto('Done', { mdapiDeploy: data.response, status: mdTransferMessages.getMessage(data.response.status) }); - this.ms.stop(); - }); - - deploy.onCancel((data) => { - this.ms.updateData({ mdapiDeploy: data, status: mdTransferMessages.getMessage(data?.status ?? 'Canceled') }); - - this.ms.stop(new Error('Deploy canceled')); - }); - - deploy.onError((error: Error) => { - if (error.message.includes('client has timed out')) { - this.ms.updateData({ status: 'Client Timeout' }); - } - - this.ms.stop(error); - throw error; - }); - const result = await deploy.pollStatus({ timeout: flags.wait }); process.exitCode = determineExitCode(result); const formatter = new DeployResultFormatter(result, flags); @@ -399,8 +280,8 @@ export default class DeployMetadata extends SfCommand { protected catch(error: Error | SfError): Promise { if (error instanceof SourceConflictError && error.data) { if (!this.jsonEnabled()) { - this.ms.updateData({ status: 'Failed' }); - this.ms.stop(error); + this.stages.update({ status: 'Failed' }); + this.stages.stop(error); writeConflictTable(error.data); // set the message and add plugin-specific actions return super.catch({ diff --git a/src/utils/multiStageOutput.ts b/src/utils/multiStageOutput.ts new file mode 100644 index 00000000..185d95f4 --- /dev/null +++ b/src/utils/multiStageOutput.ts @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2024, salesforce.com, inc. + * All rights reserved. + * Licensed under the BSD 3-Clause license. + * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import { MultiStageOutput } from '@oclif/multi-stage-output'; +import { Lifecycle, Messages } from '@salesforce/core'; +import { MetadataApiDeploy, MetadataApiDeployStatus, RequestStatus } from '@salesforce/source-deploy-retrieve'; +import { SourceMemberPollingEvent } from '@salesforce/source-tracking'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const mdTransferMessages = Messages.loadMessages('@salesforce/plugin-deploy-retrieve', 'metadata.transfer'); + +type Options = { + title: string; + jsonEnabled: boolean; +}; + +type Data = { + mdapiDeploy: MetadataApiDeployStatus; + sourceMemberPolling: SourceMemberPollingEvent; + status: string; + apiMessage: string; + username: string; +}; + +function round(value: number, precision: number): number { + const multiplier = Math.pow(10, precision || 0); + return Math.round(value * multiplier) / multiplier; +} + +function formatProgress(current: number, total: number): string { + return `${current}/${total} (${round((current / total) * 100, 0)}%)`; +} + +export class DeployStages { + private ms: MultiStageOutput; + + public constructor({ title, jsonEnabled }: Options) { + this.ms = new MultiStageOutput({ + title, + stages: [ + 'Preparing', + 'Waiting for the org to respond', + 'Deploying Metadata', + 'Running Tests', + 'Updating Source Tracking', + 'Done', + ], + jsonEnabled, + preStagesBlock: [ + { + type: 'message', + get: (data): string | undefined => data?.apiMessage, + }, + ], + postStagesBlock: [ + { + label: 'Status', + get: (data): string | undefined => data?.status, + bold: true, + type: 'dynamic-key-value', + }, + { + label: 'Deploy ID', + get: (data): string | undefined => data?.mdapiDeploy?.id, + type: 'static-key-value', + }, + { + label: 'Target Org', + get: (data): string | undefined => data?.username, + type: 'static-key-value', + }, + ], + stageSpecificBlock: [ + { + label: 'Components', + get: (data): string | undefined => + data?.mdapiDeploy?.numberComponentsTotal + ? formatProgress( + data?.mdapiDeploy?.numberComponentsDeployed ?? 0, + data?.mdapiDeploy?.numberComponentsTotal + ) + : undefined, + stage: 'Deploying Metadata', + type: 'dynamic-key-value', + }, + { + label: 'Tests', + get: (data): string | undefined => + data?.mdapiDeploy?.numberTestsTotal && data?.mdapiDeploy?.numberTestsCompleted + ? formatProgress(data?.mdapiDeploy?.numberTestsCompleted, data?.mdapiDeploy?.numberTestsTotal) + : undefined, + stage: 'Running Tests', + type: 'dynamic-key-value', + }, + { + label: 'Members', + get: (data): string | undefined => + data?.sourceMemberPolling && + formatProgress( + data.sourceMemberPolling.original - data.sourceMemberPolling.remaining, + data.sourceMemberPolling.original + ), + stage: 'Updating Source Tracking', + type: 'dynamic-key-value', + }, + ], + }); + } + + public start(username: string | undefined, deploy: MetadataApiDeploy): void { + const lifecycle = Lifecycle.getInstance(); + + this.ms.goto('Preparing', { username }); + + // for sourceMember polling events + lifecycle.on('sourceMemberPollingEvent', (event: SourceMemberPollingEvent) => + Promise.resolve(this.ms.goto('Updating Source Tracking', { sourceMemberPolling: event })) + ); + + deploy.onUpdate((data) => { + if ( + data.numberComponentsDeployed === data.numberComponentsTotal && + data.numberTestsTotal > 0 && + data.numberComponentsDeployed > 0 + ) { + this.ms.goto('Running Tests', { mdapiDeploy: data, status: mdTransferMessages.getMessage(data?.status) }); + } else if (data.status === RequestStatus.Pending) { + this.ms.goto('Waiting for the org to respond', { + mdapiDeploy: data, + status: mdTransferMessages.getMessage(data?.status), + }); + } else { + this.ms.goto('Deploying Metadata', { mdapiDeploy: data, status: mdTransferMessages.getMessage(data?.status) }); + } + }); + + deploy.onFinish((data) => { + this.ms.goto('Done', { mdapiDeploy: data.response, status: mdTransferMessages.getMessage(data.response.status) }); + this.ms.stop(); + }); + + deploy.onCancel((data) => { + this.ms.updateData({ mdapiDeploy: data, status: mdTransferMessages.getMessage(data?.status ?? 'Canceled') }); + + this.ms.stop(new Error('Deploy canceled')); + }); + + deploy.onError((error: Error) => { + if (error.message.includes('client has timed out')) { + this.ms.updateData({ status: 'Client Timeout' }); + } + + this.ms.stop(error); + throw error; + }); + } + + public update(data: Partial): void { + this.ms.updateData(data); + } + + public stop(error?: Error): void { + this.ms.stop(error); + } + + public done(data?: Partial): void { + this.ms.goto('Done', data); + } +} From 4715c03f2b18333995bae88f505ec391eaceb447 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Mon, 19 Aug 2024 16:14:57 -0600 Subject: [PATCH 13/15] fix: dont start unless deploy exists --- src/commands/project/deploy/start.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/project/deploy/start.ts b/src/commands/project/deploy/start.ts index eedf4123..9a86afd4 100644 --- a/src/commands/project/deploy/start.ts +++ b/src/commands/project/deploy/start.ts @@ -240,8 +240,6 @@ export default class DeployMetadata extends SfCommand { project ); - this.stages.start(username, deploy); - if (!deploy) { this.stages.stop(); this.log('No changes to deploy'); @@ -252,6 +250,8 @@ export default class DeployMetadata extends SfCommand { throw new SfError('The deploy id is not available.'); } + this.stages.start(username, deploy); + if (flags.async) { this.stages.done({ status: 'Queued', username }); this.stages.stop(); From 75fd0c3e94f4fa34231f5992e0f6e8fbec9baa03 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Tue, 20 Aug 2024 10:23:39 -0600 Subject: [PATCH 14/15] feat: use on deploy resume --- src/commands/project/deploy/resume.ts | 11 +++++++---- src/commands/project/deploy/start.ts | 4 ++-- src/utils/{multiStageOutput.ts => deployStages.ts} | 7 ++++--- 3 files changed, 13 insertions(+), 9 deletions(-) rename src/utils/{multiStageOutput.ts => deployStages.ts} (95%) diff --git a/src/commands/project/deploy/resume.ts b/src/commands/project/deploy/resume.ts index 3dbfe7c0..63690e59 100644 --- a/src/commands/project/deploy/resume.ts +++ b/src/commands/project/deploy/resume.ts @@ -5,13 +5,12 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import ansis from 'ansis'; import { EnvironmentVariable, Messages, Org, SfError } from '@salesforce/core'; import { SfCommand, toHelpSection, Flags } from '@salesforce/sf-plugins-core'; import { DeployResult, MetadataApiDeploy } from '@salesforce/source-deploy-retrieve'; import { Duration } from '@salesforce/kit'; +import { DeployStages } from '../../../utils/deployStages.js'; import { DeployResultFormatter } from '../../../formatters/deployResultFormatter.js'; -import { DeployProgress } from '../../../utils/progressBar.js'; import { API, DeployResultJson } from '../../../utils/types.js'; import { buildComponentSet, determineExitCode, executeDeploy, isNotResumable } from '../../../utils/deploy.js'; import { DeployCache } from '../../../utils/deployCache.js'; @@ -124,8 +123,12 @@ export default class DeployMetadataResume extends SfCommand { jobId ); - this.log(`Deploy ID: ${ansis.bold(jobId)}`); - new DeployProgress(deploy, this.jsonEnabled()).start(); + const stages = new DeployStages({ + title: 'Resuming Deploy', + jsonEnabled: this.jsonEnabled(), + }); + + stages.start({ deploy, username: deployOpts['target-org'] }); result = await deploy.pollStatus(500, wait.seconds); if (!deploy.id) { diff --git a/src/commands/project/deploy/start.ts b/src/commands/project/deploy/start.ts index 9a86afd4..da3d4be0 100644 --- a/src/commands/project/deploy/start.ts +++ b/src/commands/project/deploy/start.ts @@ -11,7 +11,7 @@ import { DeployVersionData, MetadataApiDeployStatus } from '@salesforce/source-d import { Duration } from '@salesforce/kit'; import { SfCommand, toHelpSection, Flags } from '@salesforce/sf-plugins-core'; import { SourceConflictError, SourceMemberPollingEvent } from '@salesforce/source-tracking'; -import { DeployStages } from '../../../utils/multiStageOutput.js'; +import { DeployStages } from '../../../utils/deployStages.js'; import { AsyncDeployResultFormatter } from '../../../formatters/asyncDeployResultFormatter.js'; import { DeployResultFormatter } from '../../../formatters/deployResultFormatter.js'; import { DeployResultJson, TestLevel } from '../../../utils/types.js'; @@ -250,7 +250,7 @@ export default class DeployMetadata extends SfCommand { throw new SfError('The deploy id is not available.'); } - this.stages.start(username, deploy); + this.stages.start({ username, deploy }); if (flags.async) { this.stages.done({ status: 'Queued', username }); diff --git a/src/utils/multiStageOutput.ts b/src/utils/deployStages.ts similarity index 95% rename from src/utils/multiStageOutput.ts rename to src/utils/deployStages.ts index 185d95f4..aba8309a 100644 --- a/src/utils/multiStageOutput.ts +++ b/src/utils/deployStages.ts @@ -23,6 +23,7 @@ type Data = { status: string; apiMessage: string; username: string; + id: string; }; function round(value: number, precision: number): number { @@ -64,7 +65,7 @@ export class DeployStages { }, { label: 'Deploy ID', - get: (data): string | undefined => data?.mdapiDeploy?.id, + get: (data): string | undefined => data?.id, type: 'static-key-value', }, { @@ -110,10 +111,10 @@ export class DeployStages { }); } - public start(username: string | undefined, deploy: MetadataApiDeploy): void { + public start({ username, deploy }: { username?: string | undefined; deploy: MetadataApiDeploy }): void { const lifecycle = Lifecycle.getInstance(); - this.ms.goto('Preparing', { username }); + this.ms.goto('Preparing', { username, id: deploy.id }); // for sourceMember polling events lifecycle.on('sourceMemberPollingEvent', (event: SourceMemberPollingEvent) => From 8786aa357a89656189c4ebcb045ffd7a5c5b211d Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Tue, 20 Aug 2024 10:40:46 -0600 Subject: [PATCH 15/15] feat: use DeployStages on all deploy commands --- src/commands/project/delete/source.ts | 12 ++- src/commands/project/deploy/report.ts | 7 +- src/commands/project/deploy/resume.ts | 5 +- src/commands/project/deploy/start.ts | 2 +- src/commands/project/deploy/validate.ts | 9 ++- src/utils/deployStages.ts | 13 +++- src/utils/progressBar.ts | 98 ------------------------- 7 files changed, 32 insertions(+), 114 deletions(-) delete mode 100644 src/utils/progressBar.ts diff --git a/src/commands/project/delete/source.ts b/src/commands/project/delete/source.ts index 166de79f..dfdcd5d3 100644 --- a/src/commands/project/delete/source.ts +++ b/src/commands/project/delete/source.ts @@ -33,6 +33,7 @@ import { requiredOrgFlagWithDeprecations, SfCommand, } from '@salesforce/sf-plugins-core'; +import { DeployStages } from '../../../utils/deployStages.js'; import { writeConflictTable } from '../../../utils/conflicts.js'; import { isNonDecomposedCustomLabel, isNonDecomposedCustomLabelsOrCustomLabel } from '../../../utils/metadataTypes.js'; import { getFileResponseSuccessProps, tableHeader } from '../../../utils/output.js'; @@ -41,7 +42,6 @@ import { getPackageDirs, getSourceApiVersion } from '../../../utils/project.js'; import { resolveApi, validateTests } from '../../../utils/deploy.js'; import { DeployResultFormatter } from '../../../formatters/deployResultFormatter.js'; import { DeleteResultFormatter } from '../../../formatters/deleteResultFormatter.js'; -import { DeployProgress } from '../../../utils/progressBar.js'; import { DeployCache } from '../../../utils/deployCache.js'; import { testLevelFlag, testsFlag } from '../../../utils/flags.js'; const testFlags = 'Test'; @@ -244,8 +244,14 @@ export class Source extends SfCommand { // fire predeploy event for the delete await Lifecycle.getInstance().emit('predeploy', this.components); + + const stages = new DeployStages({ + title: 'Deleting Metadata', + jsonEnabled: this.jsonEnabled(), + }); + const isRest = (await resolveApi()) === API['REST']; - this.log(`*** Deleting with ${isRest ? 'REST' : 'SOAP'} API ***`); + stages.update({ message: `Deleting with ${isRest ? 'REST' : 'SOAP'} API` }); const deploy = await this.componentSet.deploy({ usernameOrConnection: this.org.getUsername() as string, @@ -257,7 +263,7 @@ export class Source extends SfCommand { }, }); - new DeployProgress(deploy, this.jsonEnabled()).start(); + stages.start({ deploy, username: this.org.getUsername() }); this.deployResult = await deploy.pollStatus({ timeout: this.flags.wait }); if (!deploy.id) { throw new SfError('The deploy id is not available.'); diff --git a/src/commands/project/deploy/report.ts b/src/commands/project/deploy/report.ts index d36c7eeb..64f3c36b 100644 --- a/src/commands/project/deploy/report.ts +++ b/src/commands/project/deploy/report.ts @@ -8,8 +8,8 @@ import { Messages, Org, SfProject } from '@salesforce/core'; import { SfCommand, Flags } from '@salesforce/sf-plugins-core'; import { ComponentSet, DeployResult, MetadataApiDeploy, RequestStatus } from '@salesforce/source-deploy-retrieve'; +import { DeployStages } from '../../../utils/deployStages.js'; import { buildComponentSet } from '../../../utils/deploy.js'; -import { DeployProgress } from '../../../utils/progressBar.js'; import { DeployCache } from '../../../utils/deployCache.js'; import { DeployReportResultFormatter } from '../../../formatters/deployReportResultFormatter.js'; import { API, DeployResultJson } from '../../../utils/types.js'; @@ -124,7 +124,10 @@ export default class DeployMetadataReport extends SfCommand { if (wait) { // poll for deploy results try { - new DeployProgress(mdapiDeploy, this.jsonEnabled()).start(); + new DeployStages({ + title: 'Deploying Metadata', + jsonEnabled: this.jsonEnabled(), + }).start({ deploy: mdapiDeploy, username: org.getUsername() }); result = await mdapiDeploy.pollStatus(500, wait.seconds); } catch (error) { if (error instanceof Error && error.message.includes('The client has timed out')) { diff --git a/src/commands/project/deploy/resume.ts b/src/commands/project/deploy/resume.ts index 63690e59..95d25aea 100644 --- a/src/commands/project/deploy/resume.ts +++ b/src/commands/project/deploy/resume.ts @@ -123,12 +123,11 @@ export default class DeployMetadataResume extends SfCommand { jobId ); - const stages = new DeployStages({ + new DeployStages({ title: 'Resuming Deploy', jsonEnabled: this.jsonEnabled(), - }); + }).start({ deploy, username: deployOpts['target-org'] }); - stages.start({ deploy, username: deployOpts['target-org'] }); result = await deploy.pollStatus(500, wait.seconds); if (!deploy.id) { diff --git a/src/commands/project/deploy/start.ts b/src/commands/project/deploy/start.ts index da3d4be0..b798d254 100644 --- a/src/commands/project/deploy/start.ts +++ b/src/commands/project/deploy/start.ts @@ -218,7 +218,7 @@ export default class DeployMetadata extends SfCommand { lifecycle.on('apiVersionDeploy', async (apiData: DeployVersionData) => Promise.resolve( this.stages.update({ - apiMessage: messages.getMessage('apiVersionMsgDetailed', [ + message: messages.getMessage('apiVersionMsgDetailed', [ flags['dry-run'] ? 'Deploying (dry-run)' : 'Deploying', // technically manifestVersion can be undefined, but only on raw mdapi deployments. // eslint-disable-next-line @typescript-eslint/restrict-template-expressions diff --git a/src/commands/project/deploy/validate.ts b/src/commands/project/deploy/validate.ts index 2b6d7ad3..a66bde0c 100644 --- a/src/commands/project/deploy/validate.ts +++ b/src/commands/project/deploy/validate.ts @@ -11,9 +11,9 @@ import { EnvironmentVariable, Lifecycle, Messages, OrgConfigProperties, SfError import { CodeCoverageWarnings, DeployVersionData, RequestStatus } from '@salesforce/source-deploy-retrieve'; import { Duration, ensureArray } from '@salesforce/kit'; import { SfCommand, toHelpSection, Flags } from '@salesforce/sf-plugins-core'; +import { DeployStages } from '../../../utils/deployStages.js'; import { AsyncDeployResultFormatter } from '../../../formatters/asyncDeployResultFormatter.js'; import { DeployResultFormatter } from '../../../formatters/deployResultFormatter.js'; -import { DeployProgress } from '../../../utils/progressBar.js'; import { DeployResultJson, TestLevel } from '../../../utils/types.js'; import { executeDeploy, resolveApi, determineExitCode, validateTests } from '../../../utils/deploy.js'; import { DEPLOY_STATUS_CODES_DESCRIPTIONS } from '../../../utils/errorCodes.js'; @@ -195,15 +195,18 @@ export default class DeployMetadataValidate extends SfCommand if (!deploy.id) { throw new SfError('The deploy id is not available.'); } - this.log(`Deploy ID: ${ansis.bold(deploy.id)}`); if (flags.async) { + this.log(`Deploy ID: ${ansis.bold(deploy.id)}`); const asyncFormatter = new AsyncDeployResultFormatter(deploy.id); if (!this.jsonEnabled()) asyncFormatter.display(); return asyncFormatter.getJson(); } - new DeployProgress(deploy, this.jsonEnabled()).start(); + new DeployStages({ + title: 'Validating Deployment', + jsonEnabled: this.jsonEnabled(), + }).start({ deploy, username }); const result = await deploy.pollStatus(500, flags.wait?.seconds); process.exitCode = determineExitCode(result); diff --git a/src/utils/deployStages.ts b/src/utils/deployStages.ts index aba8309a..10f21f23 100644 --- a/src/utils/deployStages.ts +++ b/src/utils/deployStages.ts @@ -21,7 +21,7 @@ type Data = { mdapiDeploy: MetadataApiDeployStatus; sourceMemberPolling: SourceMemberPollingEvent; status: string; - apiMessage: string; + message: string; username: string; id: string; }; @@ -53,7 +53,7 @@ export class DeployStages { preStagesBlock: [ { type: 'message', - get: (data): string | undefined => data?.apiMessage, + get: (data): string | undefined => data?.message, }, ], postStagesBlock: [ @@ -139,8 +139,13 @@ export class DeployStages { }); deploy.onFinish((data) => { - this.ms.goto('Done', { mdapiDeploy: data.response, status: mdTransferMessages.getMessage(data.response.status) }); - this.ms.stop(); + this.ms.updateData({ mdapiDeploy: data.response, status: mdTransferMessages.getMessage(data.response.status) }); + if (data.response.status === RequestStatus.Failed) { + this.ms.stop(new Error('Failed to deploy metadata')); + } else { + this.ms.goto('Done'); + this.ms.stop(); + } }); deploy.onCancel((data) => { diff --git a/src/utils/progressBar.ts b/src/utils/progressBar.ts deleted file mode 100644 index b39a732d..00000000 --- a/src/utils/progressBar.ts +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright (c) 2022, salesforce.com, inc. - * All rights reserved. - * Licensed under the BSD 3-Clause license. - * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ - -import { envVars as env, EnvironmentVariable, Lifecycle, Messages, Logger } from '@salesforce/core'; -import { MetadataApiDeploy, MetadataApiDeployStatus } from '@salesforce/source-deploy-retrieve'; -import { Progress } from '@salesforce/sf-plugins-core'; -import { SourceMemberPollingEvent } from '@salesforce/source-tracking'; - -Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); -const mdTransferMessages = Messages.loadMessages('@salesforce/plugin-deploy-retrieve', 'metadata.transfer'); - -const showBar = Boolean( - process.env.TERM !== 'dumb' && process.stdin.isTTY && env.getBoolean(EnvironmentVariable.SF_USE_PROGRESS_BAR, true) -); - -const logger = await Logger.child('deploy-progress'); - -export class DeployProgress extends Progress { - private static OPTIONS = { - title: 'Status', - format: `%s: {status} ${showBar ? '| {bar} ' : ''}| {value}/{total} Components{errorInfo}{testInfo}{trackingInfo}`, - barCompleteChar: '\u2588', - barIncompleteChar: '\u2591', - linewrap: true, - // people really like to get text output in CI systems - // they won't get the "bar" but will get the remaining template bits this way - noTTYOutput: true, - }; - private lifecycle = Lifecycle.getInstance(); - - public constructor(private deploy: MetadataApiDeploy, jsonEnabled = false) { - super(!jsonEnabled); - } - - public start(): void { - super.start(0, { status: 'Waiting', trackingInfo: '', testInfo: '' }, DeployProgress.OPTIONS); - - // for sourceMember polling events - this.lifecycle.on('sourceMemberPollingEvent', (event: SourceMemberPollingEvent) => - Promise.resolve(this.updateTrackingProgress(event)) - ); - - this.deploy.onUpdate((data) => this.updateProgress(data)); - - // any thing else should make one final update, then stop the progress bar - this.deploy.onFinish((data) => { - this.updateProgress(data.response); - this.finish({ status: mdTransferMessages.getMessage(data.response.status) }); - }); - - this.deploy.onCancel(() => this.stop()); - - this.deploy.onError((error: Error) => { - this.stop(); - throw error; - }); - } - - private updateTrackingProgress(data: SourceMemberPollingEvent): void { - const { remaining, original } = data; - this.update(0, { - status: 'Polling SourceMembers', - trackingInfo: ` | Tracking: ${original - remaining}/${original}`, - }); - } - - private updateProgress(data: MetadataApiDeployStatus): void { - // the numCompTot. isn't computed right away, wait to start until we know how many we have - const testInfo = data.numberTestsTotal - ? ` | ${data.numberTestsCompleted ?? 0}/${data.numberTestsTotal ?? 0} Tests${ - data.numberTestErrors ? `(Errors:${data.numberTestErrors})` : '' - }` - : ''; - const errorInfo = data.numberComponentErrors > 0 ? ` | Errors: ${data.numberComponentErrors}` : ''; - - if (data.numberComponentsTotal) { - this.setTotal(data.numberComponentsTotal); - this.update(data.numberComponentsDeployed, { - errorInfo: data.numberComponentErrors > 0 ? ` | Errors: ${data.numberComponentErrors}` : '', - status: mdTransferMessages.getMessage(data.status), - testInfo, - }); - } else { - let status; - try { - status = mdTransferMessages.getMessage(data.status); - } catch (e) { - logger.debug(`data.status message lookup failed for: ${data.status}`); - status = 'Waiting'; - } - this.update(0, { errorInfo, testInfo, status }); - } - } -}