diff --git a/command-snapshot.json b/command-snapshot.json index 3e324c85c..9c6dc5e3d 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -1,4 +1,25 @@ [ + { + "command": "force:source:deploy", + "plugin": "@salesforce/plugin-source", + "flags": [ + "apiversion", + "checkonly", + "ignoreerrors", + "ignorewarnings", + "json", + "loglevel", + "manifest", + "metadata", + "runtests", + "sourcepath", + "targetusername", + "testlevel", + "validateddeployrequestid", + "verbose", + "wait" + ] + }, { "command": "force:source:retrieve", "plugin": "@salesforce/plugin-source", diff --git a/messages/deploy.json b/messages/deploy.json index d359bf691..972feb985 100644 --- a/messages/deploy.json +++ b/messages/deploy.json @@ -14,13 +14,18 @@ ], "flags": { "sourcePath": "comma-separated list of source file paths to retrieve", - "manifestParamDescription": "file path for manifest (package.xml) of components to retrieve", - "metadataParamDescription": "comma-separated list of metadata component names", - "wait": "wait time for command to finish in minutes", "manifest": "file path for manifest (package.xml) of components to retrieve", "metadata": "comma-separated list of metadata component names", + "wait": "wait time for command to finish in minutes", "packagename": "a comma-separated list of packages to retrieve", - "verbose": "verbose output of retrieve result" + "verbose": "verbose output of retrieve result", + "checkonly": "validate deploy but don’t save to the org", + "testLevel": "deployment testing level", + "runTests": "tests to run if --testlevel RunSpecifiedTests", + "ignoreErrors": "ignore any errors and do not roll back deployment", + "ignoreWarnings": "whether a warning will allow a deployment to complete successfully", + "validateDeployRequestId": "request ID of the validated deployment to run a Quick Deploy" }, - "SourceRetrieveError": "Could not retrieve files in the sourcepath%s" + "SourceRetrieveError": "Could not retrieve files in the sourcepath%s", + "checkOnlySuccess": "\nSuccessfully validated the deployment" } diff --git a/messages/retrieve.json b/messages/retrieve.json index 2b1cef701..61f074418 100644 --- a/messages/retrieve.json +++ b/messages/retrieve.json @@ -23,5 +23,14 @@ "verbose": "verbose output of retrieve result" }, "SourceRetrieveError": "Could not retrieve files in the sourcepath%s", - "retrieveTimeout": "Your retrieve request did not complete within the specified wait time [%s minutes]. Try again with a longer wait time." + "retrieveTimeout": "Your retrieve request did not complete within the specified wait time [%s minutes]. Try again with a longer wait time.", + "retrievedSourceHeader": "Retrieved Source", + "fullNameTableColumn": "FULL NAME", + "typeTableColumn": "TYPE", + "workspacePathTableColumn": "PROJECT PATH", + "NoResultsFound": "No results found", + "metadataNotFoundWarning": "WARNING: The following metadata isn’t in your org. If it’s not new, someone deleted it from the org.", + "columnNumberColumn": "COLUMN NUMBER", + "lineNumberColumn": "LINE NUMBER", + "errorColumn": "PROBLEM" } diff --git a/messages/sourceCommand.json b/messages/sourceCommand.json deleted file mode 100644 index a837c6134..000000000 --- a/messages/sourceCommand.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "SourceTrackedOrgError": "This command cannot be used on orgs that have source tracking enabled.", - "NonSourceTrackedOrgError": "This command can only be used on orgs that have source tracking enabled, such as sandboxes and scratch orgs.", - "SourceTrackedOrgErrorAction": "Run \"sfdx force:source:%s\" instead.", - "waitParamDescription": "wait time for command to finish in minutes", - "waitParamDescriptionLong": "Number of minutes to wait for the command to complete and display results to the terminal window. If the command continues to run after the wait period, the CLI returns control of the terminal window to you. The default is 33 minutes.", - "pushCommandCliPreExecute": "Pushing source changes to org %s as user %s", - "deployCommandCliPreExecute": "Deploy source to org %s as user %s", - "stateTableColumn": "STATE", - "fullNameTableColumn": "FULL NAME", - "typeTableColumn": "TYPE", - "workspacePathTableColumn": "PROJECT PATH", - "retrievedSourceHeader": "Retrieved Source", - "NoResultsFound": "No results found", - "metadataNotFoundWarning": "WARNING: The following metadata isn’t in your org. If it’s not new, someone deleted it from the org.", - "columnNumberColumn": "COLUMN NUMBER", - "lineNumberColumn": "LINE NUMBER", - "errorColumn": "PROBLEM", - "pushCommandHumanSuccess": "Pushed Source", - "pushCommandHumanError": "Push Errors", - "deployCommandHumanSuccess": "Deployed Source", - "deployCommandHumanError": "Deploy Errors", - "deleteCommandHumanSuccess": "Deleted Source", - "deleteCommandHummanError": "Delete Errors", - "DeployTimeout": "The source %s operation took longer than the specified wait time. Wait time in minutes (%s) exceeded. Increase the wait time using the --wait option, and try again.", - "mdapiCliInvalidNumericParam": "Invalid value for %s. Must be a positive numerical value.", - "SourcePathInvalid": "The sourcepath \"%s\" is not a valid source file path.", - "IllFormattedManifest": "The manifest file is improperly formatted%s.", - "InvalidManifestError": "The specified manifest file [%s] does not exist, or you don't have access to it.", - "failedToCreateManifest": "Couldn't create a manifest.", - "missingScopeOption": "Missing source scope option.", - "missingOutputDirPath": "The output directory is missing but required.", - "UnsupportedType": "The specified metadata type is unsupported: [%s]", - "flowDefinitionDeprecation": "FlowDefinition isn’t supported in the Salesforce CLI for API version 44.0 and later. Make sure that the flowDefinitions directory is empty and that you’ve upgraded the flow files per the instructions in the Metadata API Developer Guide: https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_visual_workflow.htm#md_flow_upgrade.", - "flowDeprecation": "Flows must be used with a minimum API version of 44.0. Make sure that the sfdx-project.json file is set to use \"sourceApiVersion\": \"44.0\" or later, and that you’ve upgraded the flow files per the instructions in the Metadata API Developer Guide: https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_visual_workflow.htm#md_flow_upgrade.", - "MissingRequiredParam": "Missing one of the following parameters: %s", - "XmlParsingError": "This file has an XML parsing error: %s. This is the first file we encountered; however, other files could be affected.", - "StaticResourceDeleteError": "You can't delete an individual file from a static resource using force:source:delete, only an entire directory. To delete a file, delete it from your local DX project, then use force:source:push (for scratch orgs) or force:source:deploy to update the static resource in the target org.", - "MissingComponentOrResource": "We're unable to complete this action due to a missing source file. Verify that the source file name that corresponds to the %s exists and is correct, and try again.", - "MetadataTypeDoesNotExist": "We can't find the metadata object named %s. Verify that this metadata exists and try again." -} diff --git a/src/commands/force/source/deploy.ts b/src/commands/force/source/deploy.ts new file mode 100644 index 000000000..87d09aca9 --- /dev/null +++ b/src/commands/force/source/deploy.ts @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2020, 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 * as os from 'os'; +import * as path from 'path'; +import { flags, FlagsConfig } from '@salesforce/command'; +import { Lifecycle, Messages } from '@salesforce/core'; +import { SourceDeployResult } from '@salesforce/source-deploy-retrieve'; +import { Duration } from '@salesforce/kit'; +import { asString } from '@salesforce/ts-types'; +import * as chalk from 'chalk'; +import { DEFAULT_SRC_WAIT_MINUTES, MINIMUM_SRC_WAIT_MINUTES, SourceCommand } from '../../../sourceCommand'; + +Messages.importMessagesDirectory(__dirname); +const messages = Messages.loadMessages('@salesforce/plugin-source', 'deploy'); + +export class deploy extends SourceCommand { + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessage('examples').split(os.EOL); + public static readonly requiresProject = true; + public static readonly requiresUsername = true; + public static readonly flagsConfig: FlagsConfig = { + checkonly: flags.boolean({ + char: 'c', + description: messages.getMessage('flags.checkonly'), + default: false, + }), + wait: flags.minutes({ + char: 'w', + default: Duration.minutes(DEFAULT_SRC_WAIT_MINUTES), + min: Duration.minutes(MINIMUM_SRC_WAIT_MINUTES), + description: messages.getMessage('flags.wait'), + }), + testlevel: flags.enum({ + char: 'l', + description: messages.getMessage('flags.testLevel'), + options: ['NoTestRun', 'RunSpecifiedTests', 'RunLocalTests', 'RunAllTestsInOrg'], + default: 'NoTestRun', + }), + runtests: flags.array({ + char: 'r', + description: messages.getMessage('flags.runTests'), + default: [], + }), + ignoreerrors: flags.boolean({ + char: 'o', + description: messages.getMessage('flags.ignoreErrors'), + default: false, + }), + ignorewarnings: flags.boolean({ + char: 'g', + description: messages.getMessage('flags.ignoreWarnings'), + default: false, + }), + validateddeployrequestid: flags.id({ + char: 'q', + description: messages.getMessage('flags.validateDeployRequestId'), + exclusive: [ + 'manifest', + 'metadata', + 'sourcepath', + 'checkonly', + 'testlevel', + 'runtests', + 'ignoreerrors', + 'ignorewarnings', + ], + }), + verbose: flags.builtin({ + description: messages.getMessage('flags.verbose'), + }), + metadata: flags.array({ + char: 'm', + description: messages.getMessage('flags.metadata'), + exclusive: ['manifest', 'sourcepath'], + }), + sourcepath: flags.array({ + char: 'p', + description: messages.getMessage('flags.sourcePath'), + exclusive: ['manifest', 'metadata'], + }), + manifest: flags.filepath({ + char: 'x', + description: messages.getMessage('flags.manifest'), + exclusive: ['metadata', 'sourcepath'], + }), + }; + protected readonly lifecycleEventNames = ['predeploy', 'postdeploy']; + + public async run(): Promise { + if (this.flags.validatedeployrequestid) { + // TODO: return this.doDeployRecentValidation(); + } + const hookEmitter = Lifecycle.getInstance(); + + const cs = await this.createComponentSet({ + // safe to cast from the flags as an array of strings + packagenames: this.flags.packagenames as string[], + sourcepath: this.flags.sourcepath as string[], + manifest: asString(this.flags.manifest), + metadata: this.flags.metadata as string[], + }); + + await hookEmitter.emit('predeploy', { packageXmlPath: cs.getPackageXml() }); + + const results = await cs.deploy(this.org.getUsername(), { + wait: (this.flags.wait as Duration).milliseconds, + apiOptions: { + // TODO: build out more api options + checkOnly: this.flags.checkonly as boolean, + ignoreWarnings: this.flags.ignorewarnings as boolean, + runTests: this.flags.runtests as string[], + }, + }); + + await hookEmitter.emit('postdeploy', results); + + this.print(results); + + return results; + } + + private printComponentFailures(result: SourceDeployResult): void { + if (result.status === 'Failed' && result.components) { + // sort by filename then fullname + const failures = result.components + .sort((i, j) => { + return i.component.type.directoryName < j.component.type.directoryName ? -1 : 1; + }) + .sort((i, j) => { + return i.component.fullName < j.component.fullName ? -1 : 1; + }); + this.ux.log(''); + this.ux.styledHeader(chalk.red(`Component Failures [${failures.length}]`)); + this.ux.table(failures, { + // TODO: these accessors are temporary until library JSON fixes + columns: [ + { key: 'component.type.name', label: 'Type' }, + { key: 'diagnostics[0].filePath', label: 'File' }, + { key: 'component.name', label: 'Name' }, + { key: 'diagnostics[0].message', label: 'Problem' }, + ], + }); + this.ux.log(''); + } + } + + private printComponentSuccess(result: SourceDeployResult): void { + if (result.success && result.components) { + if (result.components.length > 0) { + // sort by type then filename then fullname + const files = result.components.sort((i, j) => { + if (i.component.type.name === j.component.type.name) { + // same metadata type, according to above comment sort on filename + if (i.component.type.directoryName === j.component.type.directoryName) { + // same filename's according to comment sort by fullName + return i.component.fullName < j.component.fullName ? -1 : 1; + } + return i.component.type.directoryName < j.component.type.directoryName ? -1 : 1; + } + return i.component.type.name < j.component.type.name ? -1 : 1; + }); + // get relative path for table output + files.map((file) => { + if (file.component.content) { + return (file.component.content = path.relative(process.cwd(), file.component.content)); + } + }); + this.ux.log(''); + this.ux.styledHeader(chalk.blue('Deployed Source')); + this.ux.table(files, { + // TODO: these accessors are temporary until library JSON fixes + columns: [ + { key: 'component.name', label: 'FULL NAME' }, + { key: 'component.type.name', label: 'TYPE' }, + { key: 'component.content', label: 'PROJECT PATH' }, + ], + }); + } + } + } + + private print(result: SourceDeployResult): SourceDeployResult { + this.printComponentSuccess(result); + this.printComponentFailures(result); + // TODO: this.printTestResults(result); <- this has WI @W-8903671@ + if (result.success && this.flags.checkonly) { + this.log(messages.getMessage('checkOnlySuccess')); + } + + return result; + } +} diff --git a/src/commands/force/source/retrieve.ts b/src/commands/force/source/retrieve.ts index 583163717..706135421 100644 --- a/src/commands/force/source/retrieve.ts +++ b/src/commands/force/source/retrieve.ts @@ -11,6 +11,7 @@ import { Lifecycle, Messages, SfdxError } from '@salesforce/core'; import { SourceRetrieveResult } from '@salesforce/source-deploy-retrieve'; import { Duration } from '@salesforce/kit'; import { asString } from '@salesforce/ts-types'; +import { blue, yellow } from 'chalk'; import { DEFAULT_SRC_WAIT_MINUTES, MINIMUM_SRC_WAIT_MINUTES, SourceCommand } from '../../../sourceCommand'; Messages.importMessagesDirectory(__dirname); @@ -82,4 +83,35 @@ export class retrieve extends SourceCommand { return results; } + + /** + * to print the results table of successes, failures, partial failures + * + * @param results what the .deploy or .retrieve method returns + * @param withoutState a boolean to add state, default to true + */ + public printTable(results: SourceRetrieveResult, withoutState?: boolean): void { + const stateCol = withoutState ? [] : [{ key: 'state', label: messages.getMessage('stateTableColumn') }]; + + this.ux.styledHeader(blue(messages.getMessage('retrievedSourceHeader'))); + if (results.success && results.successes.length) { + const columns = [ + { key: 'properties.fullName', label: messages.getMessage('fullNameTableColumn') }, + { key: 'properties.type', label: messages.getMessage('typeTableColumn') }, + { + key: 'properties.fileName', + label: messages.getMessage('workspacePathTableColumn'), + }, + ]; + this.ux.table(results.successes, { columns: [...stateCol, ...columns] }); + } else { + this.ux.log(messages.getMessage('NoResultsFound')); + } + + if (results.status === 'PartialSuccess' && results.successes.length && results.failures.length) { + this.ux.log(''); + this.ux.styledHeader(yellow(messages.getMessage('metadataNotFoundWarning'))); + results.failures.forEach((warning) => this.ux.log(warning.message)); + } + } } diff --git a/src/sourceCommand.ts b/src/sourceCommand.ts index 1bf1dca29..47c2e75f4 100644 --- a/src/sourceCommand.ts +++ b/src/sourceCommand.ts @@ -6,13 +6,9 @@ */ import * as path from 'path'; import { SfdxCommand } from '@salesforce/command'; -import { ComponentSet, SourceRetrieveResult } from '@salesforce/source-deploy-retrieve'; -import { fs, Messages, PackageDir, SfdxError, SfdxProjectJson } from '@salesforce/core'; +import { ComponentSet } from '@salesforce/source-deploy-retrieve'; +import { fs, PackageDir, SfdxError, SfdxProjectJson } from '@salesforce/core'; import { ComponentLike } from '@salesforce/source-deploy-retrieve/lib/src/common'; -import { blue, yellow } from 'chalk'; - -Messages.importMessagesDirectory(__dirname); -const messages = Messages.loadMessages('@salesforce/plugin-source', 'sourceCommand'); export type FlagOptions = { packagenames?: string[]; @@ -44,6 +40,7 @@ export abstract class SourceCommand extends SfdxCommand { if (options.sourcepath) { options.sourcepath.forEach((filepath) => { if (fs.fileExistsSync(filepath)) { + this.logger.debug(`Creating ComponentSet from sourcepath ${path.resolve(filepath)}`); setAggregator.push(ComponentSet.fromSource(path.resolve(filepath))); } else { throw SfdxError.create('@salesforce/plugin-source', 'sourceCommand', 'SourcePathInvalid', [filepath]); @@ -60,11 +57,14 @@ export abstract class SourceCommand extends SfdxCommand { }) // for the requested ones get the ComponentSet from their path .forEach((pkg) => { + this.logger.debug(`Creating ComponentSet from source with ${path.resolve(pkg.path)}`); setAggregator.push(ComponentSet.fromSource(path.resolve(pkg.path))); }); } if (options.manifest) { + this.logger.debug(`Creating ComponentSet from manifest ${path.resolve(options.manifest)}`); + setAggregator.push( await ComponentSet.fromManifestFile(options.manifest, { // to create a link to the actual source component we need to have it resolve through all packages @@ -88,8 +88,11 @@ export abstract class SourceCommand extends SfdxCommand { metadata.type = splitEntry[0]; metadata.fullName = splitEntry[1]; } + this.logger.debug(`Creating ComponentSet from metadata member ${metadata.type}:${metadata.fullName}`); + const cs = new ComponentSet([metadata]); - // not sure about the process.cwd() + // we need to search the entire project for the matching metadata component + // no better way than to have it search than process.cwd() cs.resolveSourceComponents(process.cwd(), { filter: cs }); setAggregator.push(cs); }); @@ -104,35 +107,4 @@ export abstract class SourceCommand extends SfdxCommand { return new ComponentSet(merged); } - - /** - * to print the results table of successes, failures, partial failures - * - * @param results what the .deploy or .retrieve method returns - * @param withoutState a boolean to add state, default to true - */ - public printTable(results: SourceRetrieveResult, withoutState?: boolean): void { - const stateCol = withoutState ? [] : [{ key: 'state', label: messages.getMessage('stateTableColumn') }]; - - this.ux.styledHeader(blue(messages.getMessage('retrievedSourceHeader'))); - if (results.success && results.successes.length) { - const columns = [ - { key: 'properties.fullName', label: messages.getMessage('fullNameTableColumn') }, - { key: 'properties.type', label: messages.getMessage('typeTableColumn') }, - { - key: 'properties.fileName', - label: messages.getMessage('workspacePathTableColumn'), - }, - ]; - this.ux.table(results.successes, { columns: [...stateCol, ...columns] }); - } else { - this.ux.log(messages.getMessage('NoResultsFound')); - } - - if (results.status === 'PartialSuccess' && results.successes.length && results.failures.length) { - this.ux.log(''); - this.ux.styledHeader(yellow(messages.getMessage('metadataNotFoundWarning'))); - results.failures.forEach((warning) => this.ux.log(warning.message)); - } - } }