Skip to content

Commit

Permalink
feat: add org open agent (#1264)
Browse files Browse the repository at this point in the history
* feat: add org open agent

* chore(release): 5.1.5-beta.0 [skip ci]

* edit messages (#1265)

* Update open.agent.md

---------

Co-authored-by: svc-cli-bot <Svc_cli_bot@salesforce.com>
Co-authored-by: Juliet Shackell <63259011+jshackell-sfdc@users.noreply.github.com>
Co-authored-by: Mike Donnalley <mdonnalley@salesforce.com>
  • Loading branch information
4 people authored Nov 15, 2024
1 parent 848cc4a commit 5a07969
Show file tree
Hide file tree
Showing 14 changed files with 366 additions and 161 deletions.
37 changes: 23 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,20 +99,29 @@ the [SandboxNuts](https://github.com/salesforcecli/plugin-org/actions/workflows/

<!-- 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)
- [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`

Expand Down
8 changes: 8 additions & 0 deletions command-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions messages/open.agent.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
22 changes: 22 additions & 0 deletions schemas/org-open-agent.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
147 changes: 11 additions & 136 deletions src/commands/org/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,34 +6,30 @@
*/

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<OrgOpenOutput> {
export class OrgOpenCommand extends OrgOpenCommandBase<OrgOpenOutput> {
public static readonly summary = messages.getMessage('summary');
public static readonly description = messages.getMessage('description');
public static readonly examples = messages.getMessages('examples');
public static readonly aliases = ['force:org:open', 'force:source:open'];
public static deprecateAliases = true;

public static readonly flags = {
...OrgOpenCommandBase.flags,
'target-org': requiredOrgFlagWithDeprecations,
'api-version': orgApiVersionFlagWithDeprecations,
private: Flags.boolean({
Expand Down Expand Up @@ -71,104 +67,18 @@ export class OrgOpenCommand extends SfCommand<OrgOpenOutput> {

public async run(): Promise<OrgOpenOutput> {
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<string> => {
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<string>(Org.Fields.INSTANCE_URL).replace(/\/$/, '');
return `${instanceUrlClean}/secur/frontdoor.jsp?sid=${accessToken}`;
};

const generateFileUrl = async (file: string, conn: Connection): Promise<string> => {
try {
const metadataResolver = new MetadataResolver();
Expand Down Expand Up @@ -225,38 +135,3 @@ const flowFileNameToId = async (conn: Connection, filePath: string): Promise<str
throw messages.createError('FlowIdNotFound', [filePath]);
}
};

/** builds the html file that does an automatic post to the frontdoor url */
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 => `
<html>
<body onload="document.body.firstElementChild.submit()">
<form method="POST" action="${instanceUrl}/secur/frontdoor.jsp">
<input type="hidden" name="sid" value="${authToken}" />
<input type="hidden" name="retURL" value="${retUrl}" />
</form>
</body>
</html>`;

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;
};
69 changes: 69 additions & 0 deletions src/commands/org/open/agent.ts
Original file line number Diff line number Diff line change
@@ -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<OrgOpenOutput> {
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<OrgOpenOutput> {
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<string> => {
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}`;
};
Loading

0 comments on commit 5a07969

Please sign in to comment.