Skip to content

Commit ca3d548

Browse files
authored
Merge pull request #1529 from salesforcecli/cd/fix-open-url
fix: improve URL handling of org open W-19992863
2 parents 8895b84 + 93b8300 commit ca3d548

File tree

9 files changed

+268
-164
lines changed

9 files changed

+268
-164
lines changed

AGENTS.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Agent Guidelines for this oclif plugin
2+
3+
This file provides guidance when working with code in this repository.
4+
5+
## Project Overview
6+
7+
This is `@salesforce/plugin-org`, an oclif plugin for the Salesforce CLI that provides commands for working with Salesforce orgs (scratch orgs, sandboxes, production orgs). It is bundled with the official Salesforce CLI and follows Salesforce's standard plugin architecture.
8+
9+
## Common Commands
10+
11+
### Development
12+
13+
```bash
14+
# Install dependencies and compile
15+
yarn install
16+
yarn build
17+
18+
# Compile TypeScript (incremental)
19+
yarn compile
20+
21+
# Run linter
22+
yarn lint
23+
24+
# Format code
25+
yarn format
26+
27+
# Run local development version of CLI
28+
./bin/dev.js org list
29+
./bin/dev.js org create scratch --help
30+
```
31+
32+
### Testing
33+
34+
```bash
35+
# Run all tests (unit + NUTs + linting + schemas)
36+
yarn test
37+
38+
# Run only unit tests
39+
yarn test:only
40+
41+
# Run unit tests in watch mode
42+
yarn test:watch
43+
44+
# Run NUTs (Non-Unit Tests) - integration tests against real orgs
45+
yarn test:nuts
46+
47+
# Run a specific NUT
48+
yarn mocha path/to/test.nut.ts
49+
```
50+
51+
### Local Development
52+
53+
```bash
54+
# Run commands via bin/dev.js, it compiles TS source on the fly (no need to run `yarn compile` after every change)
55+
./bin/dev.js org list
56+
```
57+
58+
## Architecture
59+
60+
### Command Structure
61+
62+
Commands follow oclif's file-based routing and are organized under `src/commands/org/`:
63+
64+
- `create/` - Create scratch orgs and sandboxes
65+
- `delete/` - Delete scratch orgs and sandboxes
66+
- `resume/` - Resume async org creation operations
67+
- `refresh/` - Refresh sandboxes
68+
- `list/` - List orgs and metadata
69+
- `open/` - Open orgs in browser
70+
- `enable/` and `disable/` - Manage source tracking
71+
72+
### Message Files
73+
74+
Messages are stored in `messages/*.md` files using Salesforce's message framework. Each command typically has its own message file (e.g., `create_scratch.md`, `create.sandbox.md`).
75+
76+
### Testing Structure
77+
78+
- `test/unit/` - Unit tests using Mocha + Sinon
79+
- `test/nut/` - Integration tests (NUTs) using `@salesforce/cli-plugins-testkit`
80+
- `test/shared/` - Tests for shared utilities
81+
- Sandbox NUTs (`*.sandboxNut.ts`) are extremely slow and should be run selectively via GitHub Actions
82+
83+
### Key Dependencies
84+
85+
- `@oclif/core` - CLI framework
86+
- `@salesforce/core` - Core Salesforce functionality (Org, AuthInfo, Connection, etc.)
87+
- `@salesforce/sf-plugins-core` - Shared plugin utilities and base command classes
88+
- `@salesforce/source-deploy-retrieve` - Metadata operations
89+
- `@oclif/multi-stage-output` - Progress indicators for long-running operations
90+
91+
## Testing Notes
92+
93+
- Sandbox NUTs are slow due to actual org creation/refresh operations
94+
- Use the `SandboxNuts` GitHub Action workflow instead of running locally
95+
- Unit tests should mock `@salesforce/core` components (Org, Connection, etc.)
96+
- NUTs use real orgs and require appropriate hub/production org authentication

messages/messages.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,3 @@
33
This command will expose sensitive information that allows for subsequent activity using your current authenticated session.
44
Sharing this information is equivalent to logging someone in under the current credential, resulting in unintended access and escalation of privilege.
55
For additional information, please review the authorization section of the https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_auth_web_flow.htm.
6-
7-
# SingleAccessFrontdoorError
8-
9-
Failed to generate a frontdoor URL.

src/commands/org/open.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
} from '@salesforce/sf-plugins-core';
1414
import { Messages, Org } from '@salesforce/core';
1515
import { MetadataResolver } from '@salesforce/source-deploy-retrieve';
16-
import { buildFrontdoorUrl } from '../../shared/orgOpenUtils.js';
1716
import { OrgOpenCommandBase } from '../../shared/orgOpenCommandBase.js';
1817
import { type OrgOpenOutput } from '../../shared/orgTypes.js';
1918

@@ -46,7 +45,6 @@ export class OrgOpenCommand extends OrgOpenCommandBase<OrgOpenOutput> {
4645
summary: messages.getMessage('flags.path.summary'),
4746
env: 'FORCE_OPEN_URL',
4847
exclusive: ['source-file'],
49-
parse: (input: string): Promise<string> => Promise.resolve(encodeURIComponent(decodeURIComponent(input))),
5048
}),
5149
'url-only': Flags.boolean({
5250
char: 'r',
@@ -69,16 +67,16 @@ export class OrgOpenCommand extends OrgOpenCommandBase<OrgOpenOutput> {
6967
this.org = flags['target-org'];
7068
this.connection = this.org.getConnection(flags['api-version']);
7169

72-
const [frontDoorUrl, retUrl] = await Promise.all([
73-
buildFrontdoorUrl(this.org),
74-
flags['source-file'] ? generateFileUrl(flags['source-file'], this.org) : flags.path,
75-
]);
70+
// `org.getMetadataUIURL` already generates a Frontdoor URL
71+
if (flags['source-file']) {
72+
return this.openOrgUI(flags, await generateFileUrl(flags['source-file'], this.org));
73+
}
7674

77-
return this.openOrgUI(flags, frontDoorUrl, retUrl);
75+
return this.openOrgUI(flags, await this.org.getFrontDoorUrl(flags.path));
7876
}
7977
}
8078

81-
const generateFileUrl = async (file: string, org: Org): Promise<string> => {
79+
async function generateFileUrl(file: string, org: Org): Promise<string> {
8280
try {
8381
const metadataResolver = new MetadataResolver();
8482
const components = metadataResolver.getComponentsFromPath(file);
@@ -98,6 +96,7 @@ const generateFileUrl = async (file: string, org: Org): Promise<string> => {
9896
) {
9997
throw error;
10098
}
101-
return '';
99+
// fall back to generic frontdoor URL
100+
return org.getFrontDoorUrl();
102101
}
103-
};
102+
}

src/commands/org/open/agent.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
import { Flags } from '@salesforce/sf-plugins-core';
99
import { Connection, Messages } from '@salesforce/core';
10-
import { buildFrontdoorUrl } from '../../../shared/orgOpenUtils.js';
1110
import { OrgOpenCommandBase } from '../../../shared/orgOpenCommandBase.js';
1211
import { type OrgOpenOutput } from '../../../shared/orgTypes.js';
1312

@@ -51,12 +50,9 @@ export class OrgOpenAgent extends OrgOpenCommandBase<OrgOpenOutput> {
5150
this.org = flags['target-org'];
5251
this.connection = this.org.getConnection(flags['api-version']);
5352

54-
const [frontDoorUrl, retUrl] = await Promise.all([
55-
buildFrontdoorUrl(this.org),
56-
buildRetUrl(this.connection, flags['api-name']),
57-
]);
53+
const agentBuilderRedirect = await buildRetUrl(this.connection, flags['api-name']);
5854

59-
return this.openOrgUI(flags, frontDoorUrl, encodeURIComponent(retUrl));
55+
return this.openOrgUI(flags, await this.org.getFrontDoorUrl(agentBuilderRedirect));
6056
}
6157
}
6258

src/shared/orgOpenCommandBase.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,8 @@ export abstract class OrgOpenCommandBase<T> extends SfCommand<T> {
3030
protected org!: Org;
3131
protected connection!: Connection;
3232

33-
protected async openOrgUI(flags: OrgOpenFlags, frontDoorUrl: string, retUrl?: string): Promise<OrgOpenOutput> {
33+
protected async openOrgUI(flags: OrgOpenFlags, url: string): Promise<OrgOpenOutput> {
3434
const orgId = this.org.getOrgId();
35-
const url = `${frontDoorUrl}${
36-
retUrl ? `&${frontDoorUrl.includes('.jsp?otp=') ? `startURL=${retUrl}` : `retURL=${retUrl}`}` : ''
37-
}`;
3835

3936
// TODO: better typings in sfdx-core for orgs read from auth files
4037
const username = this.org.getUsername() as string;

src/shared/orgOpenUtils.ts

Lines changed: 1 addition & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,37 +5,16 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77

8-
import { rmSync } from 'node:fs';
98
import { ChildProcess } from 'node:child_process';
109
import open, { Options } from 'open';
11-
import { Logger, Messages, Org, SfError } from '@salesforce/core';
10+
import { Logger, Messages, SfError } from '@salesforce/core';
1211
import { Duration, Env } from '@salesforce/kit';
1312

1413
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
1514
const messages = Messages.loadMessages('@salesforce/plugin-org', 'open');
16-
const sharedMessages = Messages.loadMessages('@salesforce/plugin-org', 'messages');
1715

1816
export const openUrl = async (url: string, options: Options): Promise<ChildProcess> => open(url, options);
1917

20-
export const fileCleanup = (tempFilePath: string): void =>
21-
rmSync(tempFilePath, { force: true, maxRetries: 3, recursive: true });
22-
23-
/**
24-
* This method generates and returns a frontdoor url for the given org.
25-
*
26-
* @param org org for which we generate the frontdoor url.
27-
*/
28-
29-
export const buildFrontdoorUrl = async (org: Org): Promise<string> => {
30-
try {
31-
return await org.getFrontDoorUrl();
32-
} catch (e) {
33-
if (e instanceof SfError) throw e;
34-
const err = e as Error;
35-
throw new SfError(sharedMessages.getMessage('SingleAccessFrontdoorError'), err.message);
36-
}
37-
};
38-
3918
export const handleDomainError = (err: unknown, url: string, env: Env): string => {
4019
if (err instanceof Error) {
4120
if (err.message.includes('timeout')) {
@@ -55,26 +34,7 @@ export const handleDomainError = (err: unknown, url: string, env: Env): string =
5534
throw err;
5635
};
5736

58-
/** builds the html file that does an automatic post to the frontdoor url */
59-
export const getFileContents = (
60-
authToken: string,
61-
instanceUrl: string,
62-
// we have to defalt this to get to Setup only on the POST version. GET goes to Setup automatically
63-
retUrl = '/lightning/setup/SetupOneHome/home'
64-
): string => `
65-
<html>
66-
<body onload="document.body.firstElementChild.submit()">
67-
<form method="POST" action="${instanceUrl}/secur/frontdoor.jsp">
68-
<input type="hidden" name="sid" value="${authToken}" />
69-
<input type="hidden" name="retURL" value="${retUrl}" />
70-
</form>
71-
</body>
72-
</html>`;
73-
7437
export default {
7538
openUrl,
76-
fileCleanup,
77-
buildFrontdoorUrl,
7839
handleDomainError,
79-
getFileContents,
8040
};

test/nut/listAndDisplay.nut.ts

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { expect, config, assert } from 'chai';
1111
import { TestSession } from '@salesforce/cli-plugins-testkit';
1212
import { execCmd } from '@salesforce/cli-plugins-testkit';
1313
import { OrgListResult, defaultHubEmoji, defaultOrgEmoji } from '../../src/commands/org/list.js';
14-
import { OrgDisplayReturn, OrgOpenOutput } from '../../src/shared/orgTypes.js';
14+
import { OrgDisplayReturn } from '../../src/shared/orgTypes.js';
1515

1616
let hubOrgUsername: string;
1717
config.truncateThreshold = 0;
@@ -47,8 +47,6 @@ describe('Org Command NUT', () => {
4747
let session: TestSession;
4848
let defaultUsername: string;
4949
let aliasedUsername: string;
50-
let defaultUserOrgId: string;
51-
let aliasUserOrgId: string;
5250

5351
before(async () => {
5452
session = await TestSession.create({
@@ -78,10 +76,8 @@ describe('Org Command NUT', () => {
7876
assert(aliasOrg?.orgId);
7977

8078
defaultUsername = defaultOrg.username;
81-
defaultUserOrgId = defaultOrg.orgId;
8279

8380
aliasedUsername = aliasOrg?.username;
84-
aliasUserOrgId = aliasOrg?.orgId;
8581
});
8682

8783
after(async () => {
@@ -192,28 +188,4 @@ describe('Org Command NUT', () => {
192188
expect(usernameLine).to.include(aliasedUsername);
193189
});
194190
});
195-
describe('Org Open', () => {
196-
it('should produce the URL for an org in json', () => {
197-
const result = execCmd<OrgOpenOutput>(`org:open -o ${defaultUsername} --url-only --json`, {
198-
ensureExitCode: 0,
199-
}).jsonOutput?.result;
200-
expect(result).to.be.ok;
201-
expect(result).to.include({ orgId: defaultUserOrgId, username: defaultUsername });
202-
});
203-
it('should produce the URL with given path for an org in json', () => {
204-
const result = execCmd<OrgOpenOutput>(
205-
// see WI W-12694761 for single-quote behavior
206-
// eslint-disable-next-line sf-plugin/no-execcmd-double-quotes
207-
`force:org:open -o ${aliasedUsername} --urlonly --path "foo/bar/baz" --json`,
208-
{
209-
ensureExitCode: 0,
210-
}
211-
).jsonOutput?.result;
212-
expect(result).to.be.ok;
213-
expect(result).to.include({ orgId: aliasUserOrgId, username: aliasedUsername });
214-
expect(result)
215-
.to.property('url')
216-
.to.include(`startURL=${encodeURIComponent(decodeURIComponent('foo/bar/baz'))}`);
217-
});
218-
});
219191
});

0 commit comments

Comments
 (0)