Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add org open agent #1264

Merged
merged 5 commits into from
Nov 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading