diff --git a/README.md b/README.md index dbf96986..9cd77f03 100644 --- a/README.md +++ b/README.md @@ -99,20 +99,29 @@ the [SandboxNuts](https://github.com/salesforcecli/plugin-org/actions/workflows/ -- [`sf org create sandbox`](#sf-org-create-sandbox) -- [`sf org create scratch`](#sf-org-create-scratch) -- [`sf org delete sandbox`](#sf-org-delete-sandbox) -- [`sf org delete scratch`](#sf-org-delete-scratch) -- [`sf org disable tracking`](#sf-org-disable-tracking) -- [`sf org display`](#sf-org-display) -- [`sf org enable tracking`](#sf-org-enable-tracking) -- [`sf org list`](#sf-org-list) -- [`sf org list metadata`](#sf-org-list-metadata) -- [`sf org list metadata-types`](#sf-org-list-metadata-types) -- [`sf org open`](#sf-org-open) -- [`sf org refresh sandbox`](#sf-org-refresh-sandbox) -- [`sf org resume sandbox`](#sf-org-resume-sandbox) -- [`sf org resume scratch`](#sf-org-resume-scratch) +- [plugin-org](#plugin-org) + - [About Salesforce CLI plugins](#about-salesforce-cli-plugins) + - [Install](#install) + - [Issues](#issues) + - [Contributing](#contributing) + - [CLA](#cla) + - [Build](#build) + - [Sandbox NUTs](#sandbox-nuts) + - [Commands](#commands) + - [`sf org create sandbox`](#sf-org-create-sandbox) + - [`sf org create scratch`](#sf-org-create-scratch) + - [`sf org delete sandbox`](#sf-org-delete-sandbox) + - [`sf org delete scratch`](#sf-org-delete-scratch) + - [`sf org disable tracking`](#sf-org-disable-tracking) + - [`sf org display`](#sf-org-display) + - [`sf org enable tracking`](#sf-org-enable-tracking) + - [`sf org list`](#sf-org-list) + - [`sf org list metadata`](#sf-org-list-metadata) + - [`sf org list metadata-types`](#sf-org-list-metadata-types) + - [`sf org open`](#sf-org-open) + - [`sf org refresh sandbox`](#sf-org-refresh-sandbox) + - [`sf org resume sandbox`](#sf-org-resume-sandbox) + - [`sf org resume scratch`](#sf-org-resume-scratch) ## `sf org create sandbox` diff --git a/command-snapshot.json b/command-snapshot.json index 5b15dfe1..769326fb 100644 --- a/command-snapshot.json +++ b/command-snapshot.json @@ -136,6 +136,14 @@ ], "plugin": "@salesforce/plugin-org" }, + { + "alias": [], + "command": "org:open:agent", + "flagAliases": ["urlonly"], + "flagChars": ["b", "n", "o", "r"], + "flags": ["api-version", "browser", "flags-dir", "json", "name", "private", "target-org", "url-only"], + "plugin": "@salesforce/plugin-org" + }, { "alias": [], "command": "org:refresh:sandbox", diff --git a/messages/open.agent.md b/messages/open.agent.md new file mode 100644 index 00000000..448aab90 --- /dev/null +++ b/messages/open.agent.md @@ -0,0 +1,41 @@ +# summary + +Open an agent in your org's Agent Builder UI in a browser. + +# description + +Use the --name flag to open an agent using its API name in the Agent Builder UI of your org. To find the agent's API name, go to Setup in your org and navigate to the agent's details page. + +To generate the URL but not launch it in your browser, specify --url-only. + +To open Agent Builder in a specific browser, use the --browser flag. Supported browsers are "chrome", "edge", and "firefox". If you don't specify --browser, the org opens in your default browser. + +# examples + +- Open the agent with API name Coral_Cloud_Agent in your default org using your default browser: + + $ <%= config.bin %> <%= command.id %> --name Coral_Cloud_Agent + +- Open the agent in an incognito window of your default browser: + + $ <%= config.bin %> <%= command.id %> --private --name Coral_Cloud_Agent: + +- Open the agent in an org with alias MyTestOrg1 using the Firefox browser: + + $ <%= config.bin %> <%= command.id %> --target-org MyTestOrg1 --browser firefox --name Coral_Cloud_Agent + +# flags.name.summary + +API name, also known as developer name, of the agent you want to open in the org's Agent Builder UI. + +# flags.private.summary + +Open the org in the default browser using private (incognito) mode. + +# flags.browser.summary + +Browser where the org opens. + +# flags.url-only.summary + +Display navigation URL, but don’t launch browser. diff --git a/package.json b/package.json index 0acc694c..03659763 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@salesforce/plugin-org", "description": "Commands to interact with Salesforce orgs", - "version": "5.1.5", + "version": "5.1.5-beta.0", "author": "Salesforce", "bugs": "https://github.com/forcedotcom/cli/issues", "dependencies": { diff --git a/schemas/org-open-agent.json b/schemas/org-open-agent.json new file mode 100644 index 00000000..3522eaab --- /dev/null +++ b/schemas/org-open-agent.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$ref": "#/definitions/OrgOpenOutput", + "definitions": { + "OrgOpenOutput": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "username": { + "type": "string" + }, + "orgId": { + "type": "string" + } + }, + "required": ["url", "username", "orgId"], + "additionalProperties": false + } + } +} diff --git a/src/commands/org/open.ts b/src/commands/org/open.ts index 635ad584..986a36a3 100644 --- a/src/commands/org/open.ts +++ b/src/commands/org/open.ts @@ -6,27 +6,22 @@ */ import path from 'node:path'; -import { platform, tmpdir } from 'node:os'; -import fs from 'node:fs'; -import { execSync } from 'node:child_process'; import { Flags, loglevel, orgApiVersionFlagWithDeprecations, requiredOrgFlagWithDeprecations, - SfCommand, } from '@salesforce/sf-plugins-core'; -import isWsl from 'is-wsl'; -import { Connection, Logger, Messages, Org, SfdcUrl, SfError } from '@salesforce/core'; -import { Duration, Env, sleep } from '@salesforce/kit'; +import { Connection, Messages } from '@salesforce/core'; import { MetadataResolver } from '@salesforce/source-deploy-retrieve'; -import { apps } from 'open'; -import utils from '../../shared/utils.js'; +import { buildFrontdoorUrl } from '../../shared/orgOpenUtils.js'; +import { OrgOpenCommandBase } from '../../shared/orgOpenCommandBase.js'; +import { type OrgOpenOutput } from '../../shared/orgTypes.js'; Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); const messages = Messages.loadMessages('@salesforce/plugin-org', 'open'); -export class OrgOpenCommand extends SfCommand { +export class OrgOpenCommand extends OrgOpenCommandBase { public static readonly summary = messages.getMessage('summary'); public static readonly description = messages.getMessage('description'); public static readonly examples = messages.getMessages('examples'); @@ -34,6 +29,7 @@ export class OrgOpenCommand extends SfCommand { public static deprecateAliases = true; public static readonly flags = { + ...OrgOpenCommandBase.flags, 'target-org': requiredOrgFlagWithDeprecations, 'api-version': orgApiVersionFlagWithDeprecations, private: Flags.boolean({ @@ -71,104 +67,18 @@ export class OrgOpenCommand extends SfCommand { public async run(): Promise { const { flags } = await this.parse(OrgOpenCommand); - const conn = flags['target-org'].getConnection(flags['api-version']); + this.org = flags['target-org']; + this.connection = this.org.getConnection(flags['api-version']); - const env = new Env(); const [frontDoorUrl, retUrl] = await Promise.all([ - buildFrontdoorUrl(flags['target-org'], conn), - flags['source-file'] ? generateFileUrl(flags['source-file'], conn) : flags.path, + buildFrontdoorUrl(this.org, this.connection), + flags['source-file'] ? generateFileUrl(flags['source-file'], this.connection) : flags.path, ]); - const url = `${frontDoorUrl}${retUrl ? `&retURL=${retUrl}` : ''}`; - - const orgId = flags['target-org'].getOrgId(); - // TODO: better typings in sfdx-core for orgs read from auth files - const username = flags['target-org'].getUsername() as string; - const output = { orgId, url, username }; - // NOTE: Deliberate use of `||` here since getBoolean() defaults to false, and we need to consider both env vars. - const containerMode = env.getBoolean('SF_CONTAINER_MODE') || env.getBoolean('SFDX_CONTAINER_MODE'); - - // security warning only for --json OR --url-only OR containerMode - if (flags['url-only'] || Boolean(flags.json) || containerMode) { - const sharedMessages = Messages.loadMessages('@salesforce/plugin-org', 'messages'); - this.warn(sharedMessages.getMessage('SecurityWarning')); - this.log(''); - } - - if (containerMode) { - // instruct the user that they need to paste the URL into the browser - this.styledHeader('Action Required!'); - this.log(messages.getMessage('containerAction', [orgId, url])); - return output; - } - - if (flags['url-only']) { - // this includes the URL - this.logSuccess(messages.getMessage('humanSuccess', [orgId, username, url])); - return output; - } - - this.logSuccess(messages.getMessage('humanSuccessNoUrl', [orgId, username])); - // we actually need to open the org - try { - this.spinner.start(messages.getMessage('domainWaiting')); - await new SfdcUrl(url).checkLightningDomain(); - this.spinner.stop(); - } catch (err) { - handleDomainError(err, url, env); - } - - // create a local html file that contains the POST stuff. - const tempFilePath = path.join(tmpdir(), `org-open-${new Date().valueOf()}.html`); - await fs.promises.writeFile( - tempFilePath, - getFileContents( - conn.accessToken as string, - conn.instanceUrl, - // the path flag is URI-encoded in its `parse` func. - // For the form redirect to work we need it decoded. - flags.path ? decodeURIComponent(flags.path) : retUrl - ) - ); - const filePathUrl = isWsl - ? 'file:///' + execSync(`wslpath -m ${tempFilePath}`).toString().trim() - : `file:///${tempFilePath}`; - const cp = await utils.openUrl(filePathUrl, { - ...(flags.browser ? { app: { name: apps[flags.browser] } } : {}), - ...(flags.private ? { newInstance: platform() === 'darwin', app: { name: apps.browserPrivate } } : {}), - }); - cp.on('error', (err) => { - fileCleanup(tempFilePath); - throw SfError.wrap(err); - }); - // so we don't delete the file while the browser is still using it - // open returns when the CP is spawned, but there's not way to know if the browser is still using the file - await sleep(platform() === 'win32' || isWsl ? 7000 : 5000); - fileCleanup(tempFilePath); - - return output; + return this.openOrgUI(flags, frontDoorUrl, retUrl); } } -export type OrgOpenOutput = { - url: string; - username: string; - orgId: string; -}; - -const fileCleanup = (tempFilePath: string): void => - fs.rmSync(tempFilePath, { force: true, maxRetries: 3, recursive: true }); - -const buildFrontdoorUrl = async (org: Org, conn: Connection): Promise => { - await org.refreshAuth(); // we need a live accessToken for the frontdoor url - const accessToken = conn.accessToken; - if (!accessToken) { - throw new SfError('NoAccessToken', 'NoAccessToken'); - } - const instanceUrlClean = org.getField(Org.Fields.INSTANCE_URL).replace(/\/$/, ''); - return `${instanceUrlClean}/secur/frontdoor.jsp?sid=${accessToken}`; -}; - const generateFileUrl = async (file: string, conn: Connection): Promise => { try { const metadataResolver = new MetadataResolver(); @@ -225,38 +135,3 @@ const flowFileNameToId = async (conn: Connection, filePath: string): Promise ` - - -
- - -
- -`; - -const handleDomainError = (err: unknown, url: string, env: Env): string => { - if (err instanceof Error) { - if (err.message.includes('timeout')) { - const host = /https?:\/\/([^.]*)/.exec(url)?.[1]; - if (!host) { - throw new SfError('InvalidUrl', 'InvalidUrl'); - } - const domain = `https://${host}.lightning.force.com`; - const domainRetryTimeout = env.getNumber('SF_DOMAIN_RETRY') ?? env.getNumber('SFDX_DOMAIN_RETRY', 240); - const timeout = new Duration(domainRetryTimeout, Duration.Unit.SECONDS); - const logger = Logger.childFromRoot('org:open'); - logger.debug(`Did not find IP for ${domain} after ${timeout.seconds} seconds`); - throw new SfError(messages.getMessage('domainTimeoutError'), 'domainTimeoutError'); - } - throw SfError.wrap(err); - } - throw err; -}; diff --git a/src/commands/org/open/agent.ts b/src/commands/org/open/agent.ts new file mode 100644 index 00000000..62d99c3d --- /dev/null +++ b/src/commands/org/open/agent.ts @@ -0,0 +1,69 @@ +/* + * 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 { Flags } from '@salesforce/sf-plugins-core'; +import { Connection, Messages } from '@salesforce/core'; +import { buildFrontdoorUrl } from '../../../shared/orgOpenUtils.js'; +import { OrgOpenCommandBase } from '../../../shared/orgOpenCommandBase.js'; +import { type OrgOpenOutput } from '../../../shared/orgTypes.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-org', 'open.agent'); + +export class OrgOpenAgent extends OrgOpenCommandBase { + public static readonly summary = messages.getMessage('summary'); + public static readonly description = messages.getMessage('description'); + public static readonly examples = messages.getMessages('examples'); + public static readonly state = 'beta'; + + public static readonly flags = { + ...OrgOpenCommandBase.flags, + 'target-org': Flags.requiredOrg(), + 'api-version': Flags.orgApiVersion(), + name: Flags.string({ + char: 'n', + summary: messages.getMessage('flags.name.summary'), + required: true, + }), + private: Flags.boolean({ + summary: messages.getMessage('flags.private.summary'), + exclusive: ['url-only', 'browser'], + }), + browser: Flags.option({ + char: 'b', + summary: messages.getMessage('flags.browser.summary'), + options: ['chrome', 'edge', 'firefox'] as const, // These are ones supported by "open" package + exclusive: ['url-only', 'private'], + })(), + 'url-only': Flags.boolean({ + char: 'r', + summary: messages.getMessage('flags.url-only.summary'), + aliases: ['urlonly'], + deprecateAliases: true, + }), + }; + + public async run(): Promise { + const { flags } = await this.parse(OrgOpenAgent); + this.org = flags['target-org']; + this.connection = this.org.getConnection(flags['api-version']); + + const [frontDoorUrl, retUrl] = await Promise.all([ + buildFrontdoorUrl(this.org, this.connection), + buildRetUrl(this.connection, flags.name), + ]); + + return this.openOrgUI(flags, frontDoorUrl, retUrl); + } +} + +// Build the URL part to the Agent Builder given a Bot API name. +const buildRetUrl = async (conn: Connection, botName: string): Promise => { + const query = `SELECT id FROM BotDefinition WHERE DeveloperName='${botName}'`; + const botId = (await conn.singleRecordQuery<{ Id: string }>(query)).Id; + return `AiCopilot/copilotStudio.app#/copilot/builder?copilotId=${botId}`; +}; diff --git a/src/shared/orgOpenCommandBase.ts b/src/shared/orgOpenCommandBase.ts new file mode 100644 index 00000000..e0b33568 --- /dev/null +++ b/src/shared/orgOpenCommandBase.ts @@ -0,0 +1,107 @@ +/* + * 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 path from 'node:path'; +import fs from 'node:fs'; +import { platform, tmpdir } from 'node:os'; +import { execSync } from 'node:child_process'; +import isWsl from 'is-wsl'; +import { apps } from 'open'; +import { SfCommand } from '@salesforce/sf-plugins-core'; +import { Connection, Messages, Org, SfdcUrl, SfError } from '@salesforce/core'; +import { env, sleep } from '@salesforce/kit'; +import utils, { fileCleanup, getFileContents, handleDomainError } from './orgOpenUtils.js'; +import { type OrgOpenOutput } from './orgTypes.js'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-org', 'open'); + +type OrgOpenFlags = { + 'url-only': boolean; + browser?: 'chrome' | 'firefox' | 'edge'; + path?: string; + private: boolean; +}; + +export abstract class OrgOpenCommandBase extends SfCommand { + public static enableJsonFlag = true; + + // Set by concrete classes in `run()` + protected org!: Org; + protected connection!: Connection; + + protected async openOrgUI(flags: OrgOpenFlags, frontDoorUrl: string, retUrl?: string): Promise { + const orgId = this.org.getOrgId(); + const url = `${frontDoorUrl}${retUrl ? `&retURL=${retUrl}` : ''}`; + + // TODO: better typings in sfdx-core for orgs read from auth files + const username = this.org.getUsername() as string; + const output = { orgId, url, username }; + // NOTE: Deliberate use of `||` here since getBoolean() defaults to false, and we need to consider both env vars. + const containerMode = env.getBoolean('SF_CONTAINER_MODE') || env.getBoolean('SFDX_CONTAINER_MODE'); + + // security warning only for --json OR --url-only OR containerMode + if (flags['url-only'] || this.jsonEnabled() || containerMode) { + const sharedMessages = Messages.loadMessages('@salesforce/plugin-org', 'messages'); + this.warn(sharedMessages.getMessage('SecurityWarning')); + this.log(''); + } + + if (containerMode) { + // instruct the user that they need to paste the URL into the browser + this.styledHeader('Action Required!'); + this.log(messages.getMessage('containerAction', [orgId, url])); + return output; + } + + if (flags['url-only']) { + // this includes the URL + this.logSuccess(messages.getMessage('humanSuccess', [orgId, username, url])); + return output; + } + + this.logSuccess(messages.getMessage('humanSuccessNoUrl', [orgId, username])); + // we actually need to open the org + try { + this.spinner.start(messages.getMessage('domainWaiting')); + await new SfdcUrl(url).checkLightningDomain(); + this.spinner.stop(); + } catch (err) { + handleDomainError(err, url, env); + } + + // create a local html file that contains the POST stuff. + const tempFilePath = path.join(tmpdir(), `org-open-${new Date().valueOf()}.html`); + await fs.promises.writeFile( + tempFilePath, + getFileContents( + this.connection.accessToken as string, + this.connection.instanceUrl, + // the path flag is URI-encoded in its `parse` func. + // For the form redirect to work we need it decoded. + flags.path ? decodeURIComponent(flags.path) : retUrl + ) + ); + const filePathUrl = isWsl + ? 'file:///' + execSync(`wslpath -m ${tempFilePath}`).toString().trim() + : `file:///${tempFilePath}`; + const cp = await utils.openUrl(filePathUrl, { + ...(flags.browser ? { app: { name: apps[flags.browser] } } : {}), + ...(flags.private ? { newInstance: platform() === 'darwin', app: { name: apps.browserPrivate } } : {}), + }); + cp.on('error', (err) => { + fileCleanup(tempFilePath); + throw SfError.wrap(err); + }); + // so we don't delete the file while the browser is still using it + // open returns when the CP is spawned, but there's not way to know if the browser is still using the file + await sleep(platform() === 'win32' || isWsl ? 7000 : 5000); + fileCleanup(tempFilePath); + + return output; + } +} diff --git a/src/shared/orgOpenUtils.ts b/src/shared/orgOpenUtils.ts new file mode 100644 index 00000000..0ad91ed5 --- /dev/null +++ b/src/shared/orgOpenUtils.ts @@ -0,0 +1,73 @@ +/* + * 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 { rmSync } from 'node:fs'; +import { ChildProcess } from 'node:child_process'; +import open, { Options } from 'open'; +import { Connection, Logger, Messages, Org, SfError } from '@salesforce/core'; +import { Duration, Env } from '@salesforce/kit'; + +Messages.importMessagesDirectoryFromMetaUrl(import.meta.url); +const messages = Messages.loadMessages('@salesforce/plugin-org', 'open'); + +export const openUrl = async (url: string, options: Options): Promise => open(url, options); + +export const fileCleanup = (tempFilePath: string): void => + rmSync(tempFilePath, { force: true, maxRetries: 3, recursive: true }); + +export const buildFrontdoorUrl = async (org: Org, conn: Connection): Promise => { + await org.refreshAuth(); // we need a live accessToken for the frontdoor url + const accessToken = conn.accessToken; + if (!accessToken) { + throw new SfError('NoAccessToken', 'NoAccessToken'); + } + const instanceUrlClean = org.getField(Org.Fields.INSTANCE_URL).replace(/\/$/, ''); + return `${instanceUrlClean}/secur/frontdoor.jsp?sid=${accessToken}`; +}; + +export const handleDomainError = (err: unknown, url: string, env: Env): string => { + if (err instanceof Error) { + if (err.message.includes('timeout')) { + const host = /https?:\/\/([^.]*)/.exec(url)?.[1]; + if (!host) { + throw new SfError('InvalidUrl', 'InvalidUrl'); + } + const domain = `https://${host}.lightning.force.com`; + const domainRetryTimeout = env.getNumber('SF_DOMAIN_RETRY') ?? env.getNumber('SFDX_DOMAIN_RETRY', 240); + const timeout = new Duration(domainRetryTimeout, Duration.Unit.SECONDS); + const logger = Logger.childFromRoot('org:open'); + logger.debug(`Did not find IP for ${domain} after ${timeout.seconds} seconds`); + throw new SfError(messages.getMessage('domainTimeoutError'), 'domainTimeoutError'); + } + throw SfError.wrap(err); + } + throw err; +}; + +/** builds the html file that does an automatic post to the frontdoor url */ +export const getFileContents = ( + authToken: string, + instanceUrl: string, + // we have to defalt this to get to Setup only on the POST version. GET goes to Setup automatically + retUrl = '/lightning/setup/SetupOneHome/home' +): string => ` + + +
+ + +
+ +`; + +export default { + openUrl, + fileCleanup, + buildFrontdoorUrl, + handleDomainError, + getFileContents, +}; diff --git a/src/shared/orgTypes.ts b/src/shared/orgTypes.ts index 226cde7d..434d3e2f 100644 --- a/src/shared/orgTypes.ts +++ b/src/shared/orgTypes.ts @@ -23,6 +23,12 @@ export type OrgDisplayReturn = Partial & { sfdxAuthUrl?: string; }; +export type OrgOpenOutput = { + url: string; + username: string; + orgId: string; +}; + /** Convenience type for the fields that are in the auth file * * core's AuthFields has everything as optional. diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 47b730f7..211ef780 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -4,10 +4,8 @@ * 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 { ChildProcess } from 'node:child_process'; import { upperFirst } from '@salesforce/kit'; import { StateAggregator } from '@salesforce/core'; -import open, { Options } from 'open'; export const getAliasByUsername = async (username: string): Promise => { const stateAggregator = await StateAggregator.getInstance(); @@ -16,8 +14,6 @@ export const getAliasByUsername = async (username: string): Promise => open(url, options); - export const lowerToUpper = (object: Record): Record => // the API has keys defined in capital camel case, while the definition schema has them as lower camel case // we need to convert lower camel case to upper before merging options so they will override properly @@ -25,7 +21,6 @@ export const lowerToUpper = (object: Record): Record