Skip to content

Commit

Permalink
feat: add org open agent
Browse files Browse the repository at this point in the history
  • Loading branch information
shetzel committed Nov 12, 2024
1 parent e45dbde commit 95c6d44
Show file tree
Hide file tree
Showing 12 changed files with 337 additions and 146 deletions.
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 the Agent Builder org UI in a browser.

# description

Use the --name flag to open an agent using the developer name (aka API name) in the Agent Builder Org UI.

To generate a URL but not launch it in your browser, specify --url-only.

To open 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 developer name "Coral_Cloud_Agent using the 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 the org with alias MyTestOrg1 using the Firefox browser:

$ <%= config.bin %> <%= command.id %> --target-org MyTestOrg1 --browser firefox --name Coral_Cloud_Agent

# flags.name.summary

The developer name (aka API name) of the agent to open in the Agent Builder org 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.
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
}
}
}
146 changes: 10 additions & 136 deletions src/commands/org/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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');
Expand Down Expand Up @@ -71,104 +66,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 +134,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;
};
67 changes: 67 additions & 0 deletions src/commands/org/open/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* 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 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 95c6d44

Please sign in to comment.