diff --git a/package.json b/package.json index dc671591..53d9cff0 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@salesforce/core": "3.3.1", "@salesforce/plugin-org": "^1.6.7", "@salesforce/plugin-project-utils": "^0.0.6", + "@salesforce/sf-plugins-core": "^0.0.15", "@salesforce/ts-sinon": "^1.3.18", "@salesforce/ts-types": "^1.5.5", "axios": "^0.21.1", @@ -100,7 +101,7 @@ "bin": "sf", "topicSeparator": " ", "hooks": { - "project:findDeployers": "./lib/hooks/findDeployers" + "sf:deploy": "./lib/hooks/deploy" }, "plugins": [ "@oclif/plugin-not-found" diff --git a/src/commands/deploy/functions.ts b/src/commands/deploy/functions.ts index 94b92f2f..f9ecb805 100644 --- a/src/commands/deploy/functions.ts +++ b/src/commands/deploy/functions.ts @@ -4,8 +4,6 @@ * 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 path from 'path'; -import { URL } from 'url'; import herokuColor from '@heroku-cli/color'; import { Messages } from '@salesforce/core'; import { Flags } from '@oclif/core'; @@ -20,11 +18,11 @@ import { filterProjectReferencesToRemove, FullNameReference, splitFullName, + resolveFunctionReferences, } from '../../lib/function-reference-utils'; import Git from '../../lib/git'; -import { resolveFunctionsPaths } from '../../lib/path-utils'; -import { parseProjectToml } from '../../lib/project-toml'; -import { ComputeEnvironment, FunctionReference, SfdxProjectConfig } from '../../lib/sfdc-types'; +import { ComputeEnvironment, FunctionReference } from '../../lib/sfdc-types'; +import { fetchAppForProject, fetchOrg, fetchSfdxProject } from '../../lib/utils'; const debug = debugFactory('deploy:functions'); @@ -51,65 +49,6 @@ export default class DeployFunctions extends Command { }), }; - async getCurrentBranch() { - const statusString = await this.git?.status(); - - return statusString!.split('\n')[0].replace('On branch ', ''); - } - - async gitRemote(app: ComputeEnvironment) { - const externalApiKey = process.env.SALESFORCE_FUNCTIONS_API_KEY; - const url = new URL(app.git_url!); - - if (externalApiKey) { - url.password = externalApiKey; - url.username = ''; - - return url.toString(); - } - - const username = this.username; - const token = this.auth; - - if (!username || !token) { - this.error('No login found. Please log in using the `login:functions` command.'); - } - - url.username = username; - url.password = token; - - return url.toString(); - } - - async resolveFunctionReferences(project: SfdxProjectConfig) { - // Locate functions directory and grab paths for all function names, error if not in project or no - // functions found - const fnPaths = await resolveFunctionsPaths(); - - // Create function reference objects - return Promise.all( - fnPaths.map(async (fnPath) => { - const projectTomlPath = path.join(fnPath, 'project.toml'); - const projectToml: any = await parseProjectToml(projectTomlPath); - const fnName = projectToml.com.salesforce.id; - - const fnReference: FunctionReference = { - fullName: `${project.name}-${fnName}`, - label: fnName, - description: projectToml.com.salesforce.description, - }; - - const permissionSet = projectToml._.metadata?.permissionSet; - - if (permissionSet) { - fnReference.permissionSet = permissionSet; - } - - return fnReference; - }) - ); - } - async run() { const { flags } = await this.parse(DeployFunctions); @@ -128,17 +67,17 @@ export default class DeployFunctions extends Command { // Heroku side: Fetch git remote URL and push working branch to Heroku git server cli.action.start('Pushing changes to functions'); - const org = await this.fetchOrg(flags['connected-org']); - const project = await this.fetchSfdxProject(); + const org = await fetchOrg(flags['connected-org']); + const project = await fetchSfdxProject(); // FunctionReferences: create function reference using info from function.toml and project info // we do this early on because we don't want to bother with anything else if it turns out // there are no functions to deploy - const references = await this.resolveFunctionReferences(project); + const references = await resolveFunctionReferences(project); let app: ComputeEnvironment; try { - app = await this.fetchAppForProject(project.name, flags['connected-org']); + app = await fetchAppForProject(this.client, project.name, flags['connected-org']); } catch (error) { if (error.body.message?.includes("Couldn't find that app")) { this.error( @@ -153,13 +92,13 @@ export default class DeployFunctions extends Command { this.error('You cannot use the `--force` flag with a production org.'); } - const remote = await this.gitRemote(app); + const remote = await this.git.getRemote(app, redactedToken, this.username); debug('pushing to git server'); - const currentBranch = await this.getCurrentBranch(); + const currentBranch = await this.git.getCurrentBranch(); - const pushCommand = ['push', remote, `${flags.branch ?? currentBranch}:master`]; + const pushCommand = ['push', remote, `${flags.branch || currentBranch}:master`]; // Since we error out if they try to use `--force` with a production org, we don't check for // a production org here since this code would be unreachable in that scenario diff --git a/src/commands/env/create/compute.ts b/src/commands/env/create/compute.ts index 7ceb8c5e..1c3eafee 100644 --- a/src/commands/env/create/compute.ts +++ b/src/commands/env/create/compute.ts @@ -14,6 +14,7 @@ import { format } from 'date-fns'; import Command from '../../../lib/base'; import { FunctionsFlagBuilder } from '../../../lib/flags'; import pollForResult from '../../../lib/poll-for-result'; +import { fetchAppForProject, fetchOrg, fetchSfdxProject } from '../../../lib/utils'; interface FunctionConnectionRecord { Id: string; @@ -43,7 +44,7 @@ export default class EnvCreateCompute extends Command { const alias = flags.setalias; // if `--connected-org` is null here, fetchOrg will pull the default org from the surrounding environment - const org = await this.fetchOrg(flags['connected-org']); + const org = await fetchOrg(flags['connected-org']); const orgId = org.getOrgId(); if (!(await this.isFunctionsEnabled(org))) { @@ -62,7 +63,7 @@ export default class EnvCreateCompute extends Command { cli.action.start(`Creating compute environment for org ID ${orgId}`); - const project = await this.fetchSfdxProject(); + const project = await fetchSfdxProject(); const projectName = project.name; if (!projectName) { @@ -161,7 +162,7 @@ export default class EnvCreateCompute extends Command { // we want to fetch the existing environment so that we can point the user to it if (error.body?.message?.includes(DUPLICATE_PROJECT_MESSAGE)) { cli.action.stop('error!'); - const app = await this.fetchAppForProject(projectName, org.getUsername()); + const app = await fetchAppForProject(this.client, projectName, org.getUsername()); this.log(`${DUPLICATE_PROJECT_MESSAGE}:`); this.log(`Compute Environment ID: ${app.name}`); diff --git a/src/commands/env/delete.ts b/src/commands/env/delete.ts index f0228900..06075937 100644 --- a/src/commands/env/delete.ts +++ b/src/commands/env/delete.ts @@ -17,6 +17,7 @@ import { import { FunctionsFlagBuilder, confirmationFlag } from '../../lib/flags'; import Command from '../../lib/base'; import batchCall from '../../lib/batch-call'; +import { fetchSfdxProject } from '../../lib/utils'; Messages.importMessagesDirectory(__dirname); const messages = Messages.loadMessages('@salesforce/plugin-functions', 'env.delete'); @@ -97,7 +98,7 @@ export default class EnvDelete extends Command { // environment while the org still exists, so we need to delete all the function references // from the org as part of the cleanup process if (org) { - const project = await this.fetchSfdxProject(); + const project = await fetchSfdxProject(); const connection = org.getConnection(); let refList = await connection.metadata.list({ type: 'FunctionReference' }); refList = ensureArray(refList); diff --git a/src/commands/env/display.ts b/src/commands/env/display.ts index 4d62e481..2d8a6c8f 100644 --- a/src/commands/env/display.ts +++ b/src/commands/env/display.ts @@ -15,6 +15,7 @@ import { ComputeEnvironment, Dictionary } from '../../lib/sfdc-types'; import { FunctionsFlagBuilder } from '../../lib/flags'; import herokuVariant from '../../lib/heroku-variant'; import { ensureArray } from '../../lib/function-reference-utils'; +import { fetchSfdxProject } from '../../lib/utils'; interface EnvDisplayTable { alias?: string; @@ -101,7 +102,7 @@ export default class EnvDisplay extends Command { }); const salesOrgId = app.sales_org_connection?.sales_org_id; const org = await this.resolveOrg(salesOrgId); - const project = await this.fetchSfdxProject(); + const project = await fetchSfdxProject(); const connection = org.getConnection(); const refList = await connection.metadata.list({ type: 'FunctionReference' }); diff --git a/src/commands/env/list.ts b/src/commands/env/list.ts index 660e407d..e98f2d2e 100644 --- a/src/commands/env/list.ts +++ b/src/commands/env/list.ts @@ -13,6 +13,7 @@ import Command from '../../lib/base'; import herokuVariant from '../../lib/heroku-variant'; import { ComputeEnvironment, Dictionary } from '../../lib/sfdc-types'; import { environmentType } from '../../lib/flags'; +import { fetchSfdxProject } from '../../lib/utils'; type EnvironmentType = 'org' | 'scratchorg' | 'compute'; @@ -246,7 +247,7 @@ export default class EnvList extends Command { if (!flags.all) { try { - const project = await this.fetchSfdxProject(); + const project = await fetchSfdxProject(); if (!flags.json) { this.log(`Current environments for project ${project.name}\n`); diff --git a/src/commands/login/functions/jwt.ts b/src/commands/login/functions/jwt.ts index a4821f15..ed37ebf4 100644 --- a/src/commands/login/functions/jwt.ts +++ b/src/commands/login/functions/jwt.ts @@ -11,6 +11,7 @@ import axios from 'axios'; import { cli } from 'cli-ux'; import Command from '../../../lib/base'; import { herokuVariant } from '../../../lib/heroku-variant'; +import { fetchSfdxProject } from '../../../lib/utils'; // This is a public Oauth client created expressly for the purpose of headless auth in the functions CLI. // It does not require a client secret, is marked as public in the database and scoped accordingly @@ -73,7 +74,7 @@ export default class JwtLogin extends Command { }; if (!loginUrl) { - const project = await this.fetchSfdxProject(); + const project = await fetchSfdxProject(); // If the user passes an instance URL, we always want to defer that over trying to read their // project config or defaulting to the basic salesforce login URL. loginUrl = getString(project, 'sfdcLoginUrl', 'https://login.salesforce.com'); diff --git a/src/hooks/deploy.ts b/src/hooks/deploy.ts new file mode 100644 index 00000000..974a4129 --- /dev/null +++ b/src/hooks/deploy.ts @@ -0,0 +1,331 @@ +/* + * Copyright (c) 2021, 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 { join, basename } from 'path'; +import { SfdxProject, GlobalInfo } from '@salesforce/core'; +import herokuColor from '@heroku-cli/color'; +import { UpsertResult } from 'jsforce'; +import { Deployer, Deployable, SfHook } from '@salesforce/sf-plugins-core'; +import { cyan } from 'chalk'; +import debugFactory from 'debug'; +import APIClient, { apiUrl } from '../lib/api-client'; +import Git from '../lib/git'; +import { ComputeEnvironment, FunctionReference } from '../lib/sfdc-types'; +import { fetchAppForProject, fetchOrg, fetchSfdxProject } from '../lib/utils'; +import { + ensureArray, + filterProjectReferencesToRemove, + FullNameReference, + resolveFunctionReferences, + splitFullName, +} from '../lib/function-reference-utils'; +import batchCall from '../lib/batch-call'; + +const debug = debugFactory('deploy'); + +export type FunctionsDir = { + name: string; + fullPath: string; +}; + +export interface FunctionsDeployOptions { + username?: string; + branch?: string; + force?: boolean; + quiet?: boolean; +} + +export class FunctionsDeployable extends Deployable { + public constructor(public functionsDir: string, private parent: Deployer) { + super(); + } + + public getName(): string { + return basename(this.functionsDir); + } + + public getType(): string { + return 'function'; + } + + public getPath(): string { + return basename(this.functionsDir); + } + + public getParent(): Deployer { + return this.parent; + } +} +export class FunctionsDeployer extends Deployer { + protected info!: GlobalInfo; + protected TOKEN_BEARER_KEY = 'functions-bearer'; + private auth?: string; + private client?: APIClient; + private git?: Git; + + public static NAME = 'Functions'; + + private username!: string; + private branch!: string; + private force!: boolean; + private quiet!: boolean; + + public constructor(private functionsDir: string) { + super(); + this.deployables = [new FunctionsDeployable(functionsDir, this)]; + } + + public getName(): string { + return FunctionsDeployer.NAME; + } + + public async setup(flags: Deployer.Flags, options: FunctionsDeployOptions): Promise { + this.info = await GlobalInfo.getInstance(); + const apiKey = process.env.SALESFORCE_FUNCTIONS_API_KEY; + + if (apiKey) { + this.auth = apiKey; + } else { + const token = this.info.getToken(this.TOKEN_BEARER_KEY, true)?.token; + + if (!token) { + throw new Error('Not authenticated. Please login with `sf login functions`.'); + } + this.auth = token; + } + + this.client = new APIClient({ + auth: this.auth, + apiUrl: apiUrl(), + }); + + // We pass the api token value to the Git constructor so that it will redact it from any of + // the server logs + const redactedToken = this.auth; + this.git = new Git([redactedToken ?? '']); + + if (flags.interactive) { + this.username = await this.promptForUsername(); + this.branch = await this.promptForBranch(); + this.force = await this.promptForForce(); + this.quiet = await this.promptForQuiet(); + } else { + this.username = options.username || (await this.promptForUsername()); + this.branch = options.branch || (await this.promptForBranch()); + this.force = typeof options.force === 'boolean' ? options.force : await this.promptForForce(); + this.quiet = typeof options.quiet === 'boolean' ? options.quiet : await this.promptForQuiet(); + } + + return { + username: this.username, + branch: this.branch, + force: this.force, + quiet: this.quiet, + }; + } + + public async deploy(): Promise { + this.log(); + this.log(`Deploying ${cyan.bold(basename(this.functionsDir))}`); + + const flags = { + 'connected-org': this.username, + branch: this.branch, + force: this.force, + quiet: this.quiet, + }; + + // We don't want to deploy anything if they've got work that hasn't been committed yet because + // it could end up being really confusing since the user isn't calling git directly + if (await this.git!.hasUnpushedFiles()) { + throw new Error( + 'Your repo has files that have not been committed yet. Please either commit or stash them before deploying your project.' + ); + } + + // Heroku side: Fetch git remote URL and push working branch to Heroku git server + console.log('Pushing changes to functions'); + const org = await fetchOrg(flags['connected-org']); + const project = await fetchSfdxProject(); + + // FunctionReferences: create function reference using info from function.toml and project info + // we do this early on because we don't want to bother with anything else if it turns out + // there are no functions to deploy + const references = await resolveFunctionReferences(project); + + let app: ComputeEnvironment; + try { + app = await fetchAppForProject(this.client!, project.name, flags['connected-org']); + } catch (error) { + if (error.body.message?.includes("Couldn't find that app")) { + throw new Error( + `No compute environment found for org ${flags['connected-org']}. Please ensure you've created a compute environment before deploying.` + ); + } + + throw error; + } + + if (flags.force && app.sales_org_connection?.sales_org_stage === 'prod') { + throw new Error('You cannot use the force option with a production org.'); + } + + const remote = await this.git!.getRemote(app, this.auth!, this.username); + + debug('pushing to git server'); + + const pushCommand = ['push', remote, `${flags.branch}:master`]; + + // Since we error out if they try to use `--force` with a production org, we don't check for + // a production org here since this code would be unreachable in that scenario + if (flags.force) { + pushCommand.push('--force'); + } + + try { + await this.git!.exec(pushCommand, flags.quiet); + } catch (error) { + // if they've passed `--quiet` we don't want to show any build server output *unless* there's + // an error, in which case we want to show all of it + if (flags.quiet) { + throw new Error(error.message.replace(this.auth, '')); + } + + // In this case, they have not passed `--quiet`, in which case we have already streamed + // the entirety of the build server output and don't need to show it again + throw new Error('There was an issue when deploying your functions.'); + } + + debug('pushing function references', references); + + const connection = org.getConnection(); + + // Since the metadata upsert API can only handle 10 records at a time AND needs to run in sequence, we need to + // make sure that we're only submitting 10 records at once and then waiting for that batch to complete before + // submitting more + const results = await batchCall(references, (chunk) => + connection.metadata.upsert('FunctionReference', chunk) + ); + + results.forEach((result) => { + if (!result.success) { + throw new Error(`Unable to deploy FunctionReference for ${result.fullName}.`); + } + + if (!flags.quiet) { + this.log( + `Reference for ${result.fullName} ${ + result.created ? herokuColor.cyan('created') : herokuColor.green('updated') + }` + ); + } + }); + + // Remove any function references for functions that no longer exist + const successfulReferences = results.reduce((acc: FullNameReference[], result) => { + if (result.success) { + acc.push(splitFullName(result.fullName)); + } + + return acc; + }, []); + let refList = await connection.metadata.list({ type: 'FunctionReference' }); + refList = ensureArray(refList); + + const allReferences = refList.reduce((acc: FullNameReference[], ref) => { + acc.push(splitFullName(ref.fullName)); + + return acc; + }, []); + + const referencesToRemove = filterProjectReferencesToRemove(allReferences, successfulReferences, project.name); + + if (referencesToRemove.length) { + this.log('Removing the following functions that were deleted locally:'); + referencesToRemove.forEach((ref) => { + this.log(ref); + }); + await batchCall(referencesToRemove, (chunk) => connection.metadata.delete('FunctionReference', chunk)); + } + } + + public async promptForUsername(): Promise { + const { username } = await this.prompt<{ username: string }>([ + { + name: 'username', + message: 'Select the username or alias for the org that the compute environment should be connected to:', + type: 'input', + }, + ]); + return username; + } + + public async promptForBranch(): Promise { + const { prompt } = await this.prompt<{ prompt: boolean }>([ + { + name: 'prompt', + message: 'Would you like to deploy to a branch other than the currently active branch?', + type: 'confirm', + }, + ]); + if (prompt) { + const { branch } = await this.prompt<{ branch: string }>([ + { + name: 'branch', + message: 'Are you sure you want to force push? Please use caution when force pushing.', + type: 'input', + }, + ]); + + return branch; + } else { + return await this.git!.getCurrentBranch(); + } + } + + public async promptForForce(): Promise { + const { force } = await this.prompt<{ force: boolean }>([ + { + name: 'force', + message: 'Would you like to force push these changes to git?', + type: 'confirm', + }, + ]); + if (force) { + const { confirm } = await this.prompt<{ confirm: boolean }>([ + { + name: 'confirm', + message: 'Are you sure you want to force push? Please use caution when force pushing.', + type: 'confirm', + }, + ]); + + return confirm; + } else { + return false; + } + } + + public async promptForQuiet(): Promise { + const { quiet } = await this.prompt<{ quiet: boolean }>([ + { + name: 'quiet', + message: 'Would you like to limit the amount of output displayed from the deploy process?', + type: 'confirm', + }, + ]); + return quiet; + } +} + +const hook: SfHook.Deploy = async function (options) { + const project = await SfdxProject.resolve(); + const functionsPath = join(project.getPath(), 'functions'); + return [new FunctionsDeployer(functionsPath)]; +}; + +export default hook; diff --git a/src/hooks/findDeployers.ts b/src/hooks/findDeployers.ts deleted file mode 100644 index 315f44c6..00000000 --- a/src/hooks/findDeployers.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (c) 2021, 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 { join, basename } from 'path'; -import { fs, SfdxProject } from '@salesforce/core'; -import { Dictionary } from '@salesforce/ts-types'; -import { Deployer, Deployable, Options, Preferences } from '@salesforce/plugin-project-utils'; -import { cyan } from 'chalk'; - -export type FunctionsDir = { - name: string; - fullPath: string; -}; - -export class FunctionsDeployable extends Deployable { - public constructor(public functionsDir: string, private parent: Deployer) { - super(); - } - - public getAppName(): string { - return basename(this.functionsDir); - } - - public getAppType(): string { - return 'function'; - } - - public getAppPath(): string { - return basename(this.functionsDir); - } - - public getEnvType(): string { - return 'compute'; - } - - public getParent(): Deployer { - return this.parent; - } -} - -export class FunctionsDeployer extends Deployer { - public constructor(private functionsDir: string, protected options: Options) { - super(); - this.deployables = [new FunctionsDeployable(functionsDir, this)]; - } - - public async setup(preferences: Preferences): Promise> { - if (preferences.interactive) { - } - - return {}; - } - - public async deploy(): Promise { - this.log(); - this.log(`Deploying ${cyan.bold(basename(this.functionsDir))}`); - } -} - -const hook = async function (options: Options): Promise { - const project = await SfdxProject.resolve(); - const functionsPath = join(project.getPath(), 'functions'); - return [new FunctionsDeployer(functionsPath, options)]; -}; - -export default hook; diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index a2cf9d47..fba431b8 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -5,7 +5,7 @@ * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import { URL } from 'url'; -import { Interfaces, Errors } from '@oclif/core'; +import { Errors } from '@oclif/core'; import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import * as axiosDebugger from 'axios-debug-log'; @@ -49,7 +49,7 @@ export default class APIClient { private apiUrl: URL; - constructor(protected config: Interfaces.Config, options: APIClientConfig) { + constructor(options: APIClientConfig) { this.auth = options.auth; this.apiUrl = options.apiUrl; @@ -59,7 +59,6 @@ export default class APIClient { baseURL: `${this.apiUrl.origin}`, headers: { Accept: 'application/vnd.heroku+json; version=3', - 'user-agent': `sfdx-cli/${this.config.version} ${this.config.platform}`, ...envHeaders, }, }; @@ -110,3 +109,10 @@ export default class APIClient { return this.request(url, { ...options, method: 'DELETE' }); } } + +export function apiUrl(): URL { + const defaultUrl = 'https://api.heroku.com'; + const envVarURL = process.env.SALESFORCE_FUNCTIONS_API; + const apiURL = new URL(envVarURL || defaultUrl); + return apiURL; +} diff --git a/src/lib/base.ts b/src/lib/base.ts index 48a7ff0e..278b05e5 100644 --- a/src/lib/base.ts +++ b/src/lib/base.ts @@ -6,19 +6,11 @@ */ import { URL } from 'url'; import { Command as Base } from '@oclif/core'; -import { Aliases, AuthInfo, Config, GlobalInfo, Org, SfdxProject } from '@salesforce/core'; +import { Aliases, AuthInfo, Config, GlobalInfo, Org } from '@salesforce/core'; import { cli } from 'cli-ux'; -import APIClient from './api-client'; +import APIClient, { apiUrl } from './api-client'; import herokuVariant from './heroku-variant'; -import NetrcMachine from './netrc'; -import { ComputeEnvironment, SfdcAccount, SfdxProjectConfig } from './sfdc-types'; - -// Creds are no longer stored in netrc, but check for backwards compatibility -function checkNetRcForAuth(name = 'password') { - const key = new URL('https://sfdx-functions-netrc-key-only.com'); - const netrcMachine: NetrcMachine = new NetrcMachine(key.hostname); - return netrcMachine.get(name); -} +import { SfdcAccount } from './sfdc-types'; export default abstract class Command extends Base { protected static TOKEN_BEARER_KEY = 'functions-bearer'; @@ -37,13 +29,6 @@ export default abstract class Command extends Base { this.info = await GlobalInfo.getInstance(); } - protected get apiUrl(): URL { - const defaultUrl = 'https://api.heroku.com'; - const envVarURL = process.env.SALESFORCE_FUNCTIONS_API; - const apiURL = new URL(envVarURL || defaultUrl); - return apiURL; - } - protected get identityUrl(): URL { const defaultUrl = 'https://cli-auth.heroku.com'; const envVarUrl = process.env.SALESFORCE_FUNCTIONS_IDENTITY_URL; @@ -52,12 +37,7 @@ export default abstract class Command extends Base { } protected get username(): string { - let user = this.info.getToken(Command.TOKEN_BEARER_KEY)?.user; - - if (!user) { - // backwards compatibility - use netrc token - user = checkNetRcForAuth('login'); - } + const user = this.info.getToken(Command.TOKEN_BEARER_KEY)?.user; if (!user) throw new Error('no username found'); @@ -76,12 +56,8 @@ export default abstract class Command extends Base { if (apiKey) { this._auth = apiKey; } else { - let token = this.info.getToken(Command.TOKEN_BEARER_KEY, true)?.token; + const token = this.info.getToken(Command.TOKEN_BEARER_KEY, true)?.token; - if (!token) { - // backwards compatibility - use netrc token - token = checkNetRcForAuth(); - } if (!token) { throw new Error(`Not authenticated. Please login with \`${this.config.bin} login functions\`.`); } @@ -98,10 +74,10 @@ export default abstract class Command extends Base { const options = { auth: this.auth, - apiUrl: this.apiUrl, + apiUrl: apiUrl(), }; - this._client = new APIClient(this.config, options); + this._client = new APIClient(options); return this._client; } @@ -125,19 +101,6 @@ export default abstract class Command extends Base { return data; } - protected async fetchOrg(aliasOrUsername?: string) { - // if `aliasOrUsername` is null here, Org.create will pull the default org from the surrounding environment - return Org.create({ - aliasOrUsername, - }); - } - - protected async fetchOrgId(aliasOrUsername?: string) { - const org = await this.fetchOrg(aliasOrUsername); - - return org.getOrgId(); - } - protected async resolveOrg(orgId?: string): Promise { // We perform this check because `Org.create` blows up with a non-descriptive error message if you // just assume the defaultusername is set @@ -163,24 +126,6 @@ export default abstract class Command extends Base { return Org.create({ aliasOrUsername: defaultUsername }); } - protected async fetchSfdxProject() { - const project = await SfdxProject.resolve(); - - return project.resolveProjectConfig() as Promise; - } - - protected async fetchAppForProject(projectName: string, orgAliasOrUsername?: string) { - const orgId = await this.fetchOrgId(orgAliasOrUsername); - - const { data } = await this.client.get(`/sales-org-connections/${orgId}/apps/${projectName}`, { - headers: { - ...herokuVariant('evergreen'), - }, - }); - - return data; - } - protected async resolveAppNameForEnvironment(appNameOrAlias: string): Promise { // Check if the environment provided is an alias or not, to determine what app name we use to attempt deletion const aliases = await Aliases.create({}); diff --git a/src/lib/function-reference-utils.ts b/src/lib/function-reference-utils.ts index d72207d8..562f3b40 100644 --- a/src/lib/function-reference-utils.ts +++ b/src/lib/function-reference-utils.ts @@ -4,7 +4,11 @@ * 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 path from 'path'; import { differenceWith, isEqual } from 'lodash'; +import { resolveFunctionsPaths } from './path-utils'; +import { parseProjectToml } from './project-toml'; +import { SfdxProjectConfig, FunctionReference } from './sfdc-types'; export interface FullNameReference { project: string; @@ -40,3 +44,32 @@ export function ensureArray(refList?: T | T[]): T[] { } return refList; } + +export async function resolveFunctionReferences(project: SfdxProjectConfig) { + // Locate functions directory and grab paths for all function names, error if not in project or no + // functions found + const fnPaths = await resolveFunctionsPaths(); + + // Create function reference objects + return Promise.all( + fnPaths.map(async (fnPath) => { + const projectTomlPath = path.join(fnPath, 'project.toml'); + const projectToml: any = await parseProjectToml(projectTomlPath); + const fnName = projectToml.com.salesforce.id; + + const fnReference: FunctionReference = { + fullName: `${project.name}-${fnName}`, + label: fnName, + description: projectToml.com.salesforce.description, + }; + + const permissionSet = projectToml._.metadata?.permissionSet; + + if (permissionSet) { + fnReference.permissionSet = permissionSet; + } + + return fnReference; + }) + ); +} diff --git a/src/lib/git.ts b/src/lib/git.ts index fd615623..e551fa4e 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -4,8 +4,10 @@ * 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 execa from 'execa'; +import { URL } from 'url'; +import { ComputeEnvironment } from '../lib/sfdc-types'; import Redactor from './redactor'; +const execa = require('execa'); export class Git { redacted: string[]; @@ -48,6 +50,34 @@ export class Git { status.includes('Changes not staged for commit:') ); } + + async getCurrentBranch() { + const statusString = await this.status(); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return statusString!.split('\n')[0].replace('On branch ', ''); + } + + async getRemote(app: ComputeEnvironment, token: string, username: string) { + const externalApiKey = process.env.SALESFORCE_FUNCTIONS_API_KEY; + const url = new URL(app.git_url!); + + if (externalApiKey) { + url.password = externalApiKey; + url.username = ''; + + return url.toString(); + } + + if (!username || !token) { + throw new Error('No login found. Please log in using the `login:functions` command.'); + } + + url.username = username; + url.password = token; + + return url.toString(); + } } export default Git; diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 00000000..a0432fa6 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,42 @@ +/* + * 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 { Org, SfdxProject } from '@salesforce/core'; +import APIClient from './api-client'; +import herokuVariant from './heroku-variant'; +import { ComputeEnvironment, SfdxProjectConfig } from './sfdc-types'; + +export async function fetchOrg(aliasOrUsername?: string) { + // if `aliasOrUsername` is null here, Org.create will pull the default org from the surrounding environment + return Org.create({ + aliasOrUsername, + }); +} + +export async function fetchOrgId(aliasOrUsername?: string) { + const org = await fetchOrg(aliasOrUsername); + + return org.getOrgId(); +} + +export async function fetchSfdxProject() { + const project = await SfdxProject.resolve(); + + return project.resolveProjectConfig() as Promise; +} + +export async function fetchAppForProject(client: APIClient, projectName: string, orgAliasOrUsername?: string) { + const orgId = await fetchOrgId(orgAliasOrUsername); + + const { data } = await client.get(`/sales-org-connections/${orgId}/apps/${projectName}`, { + headers: { + ...herokuVariant('evergreen'), + }, + }); + + return data; +} diff --git a/test/commands/env/delete.test.ts b/test/commands/env/delete.test.ts index ff0f8c67..8bafeb62 100644 --- a/test/commands/env/delete.test.ts +++ b/test/commands/env/delete.test.ts @@ -8,7 +8,7 @@ import { expect, test } from '@oclif/test'; import { Aliases, Org, SfdxProject } from '@salesforce/core'; import * as sinon from 'sinon'; import EnvDelete from '../../../src/commands/env/delete'; - +import * as Utils from '../../../src/lib/utils'; const COMPUTE_ENV_NAME = 'my-new-compute-environment-100'; const COMPUTE_ENV_ALIAS = 'my-compute-alias'; @@ -105,7 +105,7 @@ describe('env:delete', () => { .do(() => { sandbox.stub(EnvDelete.prototype, 'resolveOrg' as any).returns({}); sandbox.stub(SfdxProject, 'resolve' as any).returns(PROJECT_MOCK); - sandbox.stub(EnvDelete.prototype, 'fetchOrg' as any).returns(ORG_MOCK); + sandbox.stub(Utils, 'fetchOrg' as any).returns(ORG_MOCK); }) .finally(() => { sandbox.restore(); diff --git a/test/commands/project/deploy/functions.test.ts b/test/commands/project/deploy/functions.test.ts index 87d02441..509bcba6 100644 --- a/test/commands/project/deploy/functions.test.ts +++ b/test/commands/project/deploy/functions.test.ts @@ -8,9 +8,9 @@ import { expect, test } from '@oclif/test'; import { CLIError } from '@oclif/errors'; import { Org, SfdxProject } from '@salesforce/core'; import * as sinon from 'sinon'; -import ProjectDeployFunctions from '../../../../src/commands/deploy/functions'; import Git from '../../../../src/lib/git'; import { AuthStubs } from '../../../helpers/auth'; +import * as FunctionReferenceUtils from '../../../../src/lib/function-reference-utils'; const sandbox = sinon.createSandbox(); @@ -134,7 +134,7 @@ describe('sf project deploy functions', () => { sandbox.stub(SfdxProject, 'resolve' as any).returns(PROJECT_MOCK); sandbox.stub(Org, 'create' as any).returns(ORG_MOCK); - sandbox.stub(ProjectDeployFunctions.prototype, 'resolveFunctionReferences' as any).returns(FUNCTION_REFS_MOCK); + sandbox.stub(FunctionReferenceUtils, 'resolveFunctionReferences' as any).returns(FUNCTION_REFS_MOCK); }) .finally(() => { sandbox.restore(); @@ -149,39 +149,6 @@ describe('sf project deploy functions', () => { expect(ctx.stdout).to.not.include('Removing the following functions that were deleted locally:'); }); - // Falls back to netrc - test - .stdout() - .stderr() - .do(() => { - sandbox.stub(Git.prototype as any, 'hasUnpushedFiles').returns(false); - sandbox.stub(Git.prototype, 'status' as any).returns('On branch main'); - const gitExecStub = sandbox.stub(Git.prototype, 'exec' as any); - gitExecStub.withArgs(sinon.match.array.startsWith(['push'])).returns({ stdout: '', stderr: '' }); - - sandbox.stub(SfdxProject, 'resolve' as any).returns(PROJECT_MOCK); - sandbox.stub(Org, 'create' as any).returns(ORG_MOCK); - - // Falls back to netrc - AuthStubs.getToken.returns(undefined); - AuthStubs.netrc.withArgs('login').returns('login'); - AuthStubs.netrc.withArgs('password').returns('password'); - - sandbox.stub(ProjectDeployFunctions.prototype, 'resolveFunctionReferences' as any).returns(FUNCTION_REFS_MOCK); - }) - .finally(() => { - sandbox.restore(); - }) - .nock('https://api.heroku.com', (api) => { - api.get(`/sales-org-connections/${ORG_MOCK.id}/apps/${PROJECT_CONFIG_MOCK.name}`).reply(200, ENVIRONMENT_MOCK); - }) - .command(['deploy:functions', '--connected-org=my-scratch-org']) - .it('deploys a function using netrc', (ctx) => { - expect(ctx.stdout).to.include('Reference for sweet_project-fn1 created'); - expect(ctx.stdout).to.include('Reference for sweet_project-fn2 created'); - expect(ctx.stdout).to.not.include('Removing the following functions that were deleted locally:'); - }); - // When specifying another branch test .stdout() @@ -193,7 +160,7 @@ describe('sf project deploy functions', () => { sandbox.stub(SfdxProject, 'resolve' as any).returns(PROJECT_MOCK); sandbox.stub(Org, 'create' as any).returns(ORG_MOCK_WITH_DELETED_FUNCTION); - sandbox.stub(ProjectDeployFunctions.prototype, 'resolveFunctionReferences' as any).returns(FUNCTION_REFS_MOCK); + sandbox.stub(FunctionReferenceUtils, 'resolveFunctionReferences' as any).returns(FUNCTION_REFS_MOCK); }) .add('execStub', () => { const gitExecStub = sandbox.stub(Git.prototype, 'exec' as any); @@ -229,7 +196,7 @@ describe('sf project deploy functions', () => { sandbox.stub(SfdxProject, 'resolve' as any).returns(PROJECT_MOCK); sandbox.stub(Org, 'create' as any).returns(ORG_MOCK_WITH_DELETED_FUNCTION); - sandbox.stub(ProjectDeployFunctions.prototype, 'resolveFunctionReferences' as any).returns(FUNCTION_REFS_MOCK); + sandbox.stub(FunctionReferenceUtils, 'resolveFunctionReferences' as any).returns(FUNCTION_REFS_MOCK); }) .finally(() => { sandbox.restore(); @@ -258,7 +225,7 @@ describe('sf project deploy functions', () => { AuthStubs.getToken.returns(undefined); - sandbox.stub(ProjectDeployFunctions.prototype, 'resolveFunctionReferences' as any).returns(FUNCTION_REFS_MOCK); + sandbox.stub(FunctionReferenceUtils, 'resolveFunctionReferences' as any).returns(FUNCTION_REFS_MOCK); }) .add('execStub', () => { return sandbox.stub(Git.prototype, 'exec' as any); @@ -288,7 +255,7 @@ describe('sf project deploy functions', () => { sandbox.stub(SfdxProject, 'resolve' as any).returns(PROJECT_MOCK); sandbox.stub(Org, 'create' as any).returns(ORG_MOCK); - sandbox.stub(ProjectDeployFunctions.prototype, 'resolveFunctionReferences' as any).returns(FUNCTION_REFS_MOCK); + sandbox.stub(FunctionReferenceUtils, 'resolveFunctionReferences' as any).returns(FUNCTION_REFS_MOCK); }) .add('execStub', () => { const gitExecStub = sandbox.stub(Git.prototype, 'exec' as any); @@ -329,7 +296,7 @@ describe('sf project deploy functions', () => { sandbox.stub(SfdxProject, 'resolve' as any).returns(PROJECT_MOCK); sandbox.stub(Org, 'create' as any).returns(ORG_MOCK); - sandbox.stub(ProjectDeployFunctions.prototype, 'resolveFunctionReferences' as any).returns(FUNCTION_REFS_MOCK); + sandbox.stub(FunctionReferenceUtils, 'resolveFunctionReferences' as any).returns(FUNCTION_REFS_MOCK); }) .add('execStub', () => { const gitExecStub = sandbox.stub(Git.prototype, 'exec' as any); @@ -361,7 +328,7 @@ describe('sf project deploy functions', () => { sandbox.stub(SfdxProject, 'resolve' as any).returns(PROJECT_MOCK); sandbox.stub(Org, 'create' as any).returns(ORG_MOCK); - sandbox.stub(ProjectDeployFunctions.prototype, 'resolveFunctionReferences' as any).returns(FUNCTION_REFS_MOCK); + sandbox.stub(FunctionReferenceUtils, 'resolveFunctionReferences' as any).returns(FUNCTION_REFS_MOCK); }) .add('execStub', () => { const gitExecStub = sandbox.stub(Git.prototype, 'exec' as any); @@ -397,7 +364,7 @@ describe('sf project deploy functions', () => { sandbox.stub(SfdxProject, 'resolve' as any).returns(PROJECT_MOCK); sandbox.stub(Org, 'create' as any).returns(ORG_MOCK); - sandbox.stub(ProjectDeployFunctions.prototype, 'resolveFunctionReferences' as any).returns(FUNCTION_REFS_MOCK); + sandbox.stub(FunctionReferenceUtils, 'resolveFunctionReferences' as any).returns(FUNCTION_REFS_MOCK); }) .finally(() => { sandbox.restore(); @@ -422,8 +389,7 @@ describe('sf project deploy functions', () => { sandbox.stub(SfdxProject, 'resolve' as any).returns(PROJECT_MOCK); sandbox.stub(Org, 'create' as any).returns(ORG_MOCK); - - sandbox.stub(ProjectDeployFunctions.prototype, 'resolveFunctionReferences' as any).returns([ + sandbox.stub(FunctionReferenceUtils, 'resolveFunctionReferences' as any).returns([ ...FUNCTION_REFS_MOCK, { fullName: 'sweet_project-fnerror', diff --git a/yarn.lock b/yarn.lock index 6299e633..e33b02df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -647,7 +647,7 @@ is-wsl "^2.1.1" tslib "^2.0.0" -"@oclif/core@^0.5.17", "@oclif/core@^0.5.34": +"@oclif/core@^0.5.17", "@oclif/core@^0.5.33", "@oclif/core@^0.5.34": version "0.5.34" resolved "https://registry.yarnpkg.com/@oclif/core/-/core-0.5.34.tgz#d8cadcd609929560e6cc836c45d94bc99379e1f6" integrity sha512-laLrm2tvIOr6uboDMVdAbBJMeAAgV0otkoF8imdyhYCsNcmXFyV3x0kwNGbEUYG945CQ0V00u7MS7tmlwdZlGw== @@ -1007,6 +1007,17 @@ resolved "https://registry.yarnpkg.com/@salesforce/schemas/-/schemas-1.1.0.tgz#bbf94a11ee036f2b0ec6ba82306cd9565a6ba26b" integrity sha512-6D7DvE6nFxpLyyTnrOIbbAeCJw2r/EpinFAcMh6gU0gA/CGfSbwV/8uR3uHLYL2zCyCZLH8jJ4dZ3BzCMqc+Eg== +"@salesforce/sf-plugins-core@^0.0.15": + version "0.0.15" + resolved "https://registry.yarnpkg.com/@salesforce/sf-plugins-core/-/sf-plugins-core-0.0.15.tgz#f7458258c14aad6c1819511ae9a9dd1d474b4f6a" + integrity sha512-k5i2s2mmx7prWZrZUp8FByU9Gdi7AwgpBz0cF+9TpQ8nLdqIyWZez3fNRr79SUTBFE1/sExHtN/wUJX5j5m1tA== + dependencies: + "@oclif/core" "^0.5.33" + "@salesforce/kit" "^1.5.8" + "@salesforce/ts-types" "^1.5.13" + cli-ux "^5.6.2" + inquirer "^8.1.1" + "@salesforce/templates@^52.0.0": version "52.1.0" resolved "https://registry.yarnpkg.com/@salesforce/templates/-/templates-52.1.0.tgz#d37377e93ccb5486136ac8aab976ffd3360fc3a2"