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 1 commit
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
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
Loading