Skip to content

Commit a91fb8b

Browse files
authored
Merge pull request #7 from salesforcecli/sm/org-open
Sm/org open
2 parents f040504 + 2924310 commit a91fb8b

File tree

9 files changed

+372
-50
lines changed

9 files changed

+372
-50
lines changed

.vscode/launch.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@
3636
"args": ["--inspect", "--no-timeouts", "--colors", "${file}"],
3737
"env": {
3838
"NODE_ENV": "development",
39-
"SFDX_ENV": "development"
39+
"SFDX_ENV": "development",
40+
"OCLIF_TS_NODE": "0"
4041
},
4142
"smartStep": true,
4243
"internalConsoleOptions": "openOnSessionStart",

command-snapshot.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,10 @@
88
"command": "force:org:list",
99
"plugin": "@salesforce/plugin-org",
1010
"flags": ["all", "clean", "json", "loglevel", "noprompt", "skipconnectionstatus", "verbose"]
11+
},
12+
{
13+
"command": "force:org:open",
14+
"plugin": "@salesforce/plugin-org",
15+
"flags": ["path", "urlonly", "json", "loglevel", "targetusername", "apiversion"]
1116
}
1217
]

messages/open.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"description": "open your default scratch org, or another org that you specify,\nTo open a specific page, specify the portion of the URL after \"yourInstance.salesforce.com/\" as --path.\nFor example, specify \"--path lightning\" to open Lightning Experience, or specify \"--path /apex/YourPage\" to open a Visualforce page.\nTo generate a URL but not launch your browser, specify --urlonly",
3+
"examples": [
4+
"sfdx force:org:open",
5+
"sfdx force:org:open -u [email protected]",
6+
"sfdx force:org:open -u MyTestOrg1",
7+
"sfdx force:org:open -r -p lightning"
8+
],
9+
"cliPath": "navigation URL path",
10+
"urlonly": "display navigation URL, but don’t launch browser",
11+
"containerAction": "You are in a headless environment. To access the org %s, open this URL in a browser:\n\n%s",
12+
"humanSuccess": "Access org %s as user %s with the following URL: %s",
13+
"domainWaiting": "Waiting to resolve the Lightning Experience-enabled custom domain...",
14+
"domainTimeoutError": "The Lightning Experience-enabled custom domain is unavailable.",
15+
"domainTimeoutAction": "The Lightning Experience-enabled custom domain may take a few more minutes to resolve. Try the \"force:org:open\" command again."
16+
}

package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
"dependencies": {
88
"@oclif/config": "^1",
99
"@salesforce/command": "^3.0.3",
10-
"@salesforce/core": "^2.16.3",
10+
"@salesforce/core": "^2.17.0",
11+
"@salesforce/kit": "^1.4.0",
12+
"open": "^7.3.1",
1113
"tslib": "^1"
1214
},
1315
"devDependencies": {
@@ -90,20 +92,20 @@
9092
"scripts": {
9193
"build": "sf-build",
9294
"clean": "sf-clean",
93-
"clean-all": "-clean all",
95+
"clean-all": "sf-clean all",
9496
"clean:lib": "shx rm -rf lib && shx rm -rf coverage && shx rm -rf .nyc_output && shx rm -f oclif.manifest.json",
9597
"compile": "sf-compile",
9698
"docs": "sf-docs",
9799
"format": "sf-format",
98100
"lint": "sf-lint",
99101
"postpack": "shx rm -f oclif.manifest.json",
100102
"posttest": "yarn lint && yarn test:deprecation-policy && yarn test:command-reference",
101-
"test:deprecation-policy": "./bin/run snapshot:compare",
102-
"test:command-reference": "./bin/run commandreference:generate -p @salesforce/plugin-org --erroronwarnings",
103103
"prepack": "sf-build",
104104
"prepare": "sf-install",
105105
"pretest": "sf-compile-test",
106106
"test": "sf-test",
107+
"test:command-reference": "./bin/run commandreference:generate -p @salesforce/plugin-org --erroronwarnings",
108+
"test:deprecation-policy": "./bin/run snapshot:compare",
107109
"version": "oclif-dev readme && git add README.md"
108110
},
109111
"husky": {

src/commands/force/org/open.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright (c) 2020, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import { EOL } from 'os';
8+
import { URL } from 'url';
9+
10+
import { flags, FlagsConfig, SfdxCommand } from '@salesforce/command';
11+
import { Messages, Org, MyDomainResolver, SfdxError, sfdc } from '@salesforce/core';
12+
import { Env, Duration } from '@salesforce/kit';
13+
import { openUrl } from '../../../shared/utils';
14+
15+
Messages.importMessagesDirectory(__dirname);
16+
const messages = Messages.loadMessages('@salesforce/plugin-org', 'open');
17+
export class OrgOpenCommand extends SfdxCommand {
18+
public static readonly description = messages.getMessage('description');
19+
public static readonly examples = messages.getMessage('examples').split(EOL);
20+
public static readonly requiresUsername = true;
21+
public static readonly flagsConfig: FlagsConfig = {
22+
path: flags.string({
23+
char: 'p',
24+
description: messages.getMessage('cliPath'),
25+
env: 'FORCE_OPEN_URL',
26+
parse: (input) => encodeURIComponent(decodeURIComponent(input)),
27+
}),
28+
urlonly: flags.boolean({
29+
char: 'r',
30+
description: messages.getMessage('urlonly'),
31+
}),
32+
};
33+
34+
public async run(): Promise<OrgOpenOutput> {
35+
const frontDoorUrl = await this.buildFrontdoorUrl();
36+
const url = this.flags.path ? `${frontDoorUrl}&retURL=${this.flags.path as string}` : frontDoorUrl;
37+
const orgId = this.org.getOrgId();
38+
const username = this.org.getUsername();
39+
const output = { orgId, url, username };
40+
41+
if (new Env().getBoolean('SFDX_CONTAINER_MODE')) {
42+
// instruct the user that they need to paste the URL into the browser
43+
this.ux.styledHeader('Action Required!');
44+
this.ux.log(messages.getMessage('containerAction', [orgId, url]));
45+
return output;
46+
}
47+
48+
this.ux.log(messages.getMessage('humanSuccess', [orgId, username, url]));
49+
50+
if (this.flags.urlonly) {
51+
return output;
52+
}
53+
// we actually need to open the org
54+
await this.checkLightningDomain(url);
55+
await openUrl(url);
56+
return output;
57+
}
58+
59+
private async buildFrontdoorUrl(): Promise<string> {
60+
await this.org.refreshAuth(); // we need a live accessToken for the frontdoor url
61+
const conn = this.org.getConnection();
62+
const accessToken = conn.accessToken;
63+
const instanceUrl = this.org.getField(Org.Fields.INSTANCE_URL) as string;
64+
const instanceUrlClean = instanceUrl.replace(/\/$/, '');
65+
return `${instanceUrlClean}/secur/frontdoor.jsp?sid=${accessToken}`;
66+
}
67+
68+
private async checkLightningDomain(url: string): Promise<void> {
69+
const domain = `https://${/https?:\/\/([^.]*)/.exec(url)[1]}.lightning.force.com`;
70+
const timeout = new Duration(new Env().getNumber('SFDX_DOMAIN_RETRY', 240), Duration.Unit.SECONDS);
71+
if (sfdc.isInternalUrl(url) || timeout.seconds === 0) {
72+
return;
73+
}
74+
75+
const resolver = await MyDomainResolver.create({
76+
url: new URL(domain),
77+
timeout,
78+
frequency: new Duration(1, Duration.Unit.SECONDS),
79+
});
80+
this.ux.startSpinner(messages.getMessage('domainWaiting'));
81+
82+
try {
83+
const ip = await resolver.resolve();
84+
this.logger.debug(`Found IP ${ip} for ${domain}`);
85+
return;
86+
} catch (error) {
87+
this.logger.debug(`Did not find IP for ${domain} after ${timeout.seconds} seconds`);
88+
throw new SfdxError(messages.getMessage('domainTimeoutError'), 'domainTimeoutError', [
89+
messages.getMessage('domainTimeoutAction'),
90+
]);
91+
}
92+
}
93+
}
94+
95+
interface OrgOpenOutput {
96+
url: string;
97+
username: string;
98+
orgId: string;
99+
}

src/shared/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
* Licensed under the BSD 3-Clause license.
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
7+
import { ChildProcess } from 'child_process';
78
import { Aliases } from '@salesforce/core';
9+
import * as open from 'open';
810

911
export const getAliasByUsername = async (username: string): Promise<string> => {
1012
const alias = await Aliases.create(Aliases.getDefaultOptions());
@@ -18,3 +20,7 @@ export const camelCaseToTitleCase = (text: string): string => {
1820
.replace(/([A-Z][a-z]+)/g, ' $1')
1921
.trim();
2022
};
23+
24+
export const openUrl = async (url: string): Promise<ChildProcess> => {
25+
return open(url);
26+
};

test/commands/force/org/list.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77
import { $$, expect, test } from '@salesforce/command/lib/test';
8-
98
import * as chai from 'chai';
109
import * as chaiAsPromised from 'chai-as-promised';
1110
import cli from 'cli-ux';
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/*
2+
* Copyright (c) 2020, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import { $$, expect, test } from '@salesforce/command/lib/test';
8+
import { Org, MyDomainResolver, Messages } from '@salesforce/core';
9+
import { stubMethod } from '@salesforce/ts-sinon';
10+
import * as utils from '../../../../src/shared/utils';
11+
12+
Messages.importMessagesDirectory(__dirname);
13+
const messages = Messages.loadMessages('@salesforce/plugin-org', 'open');
14+
15+
const orgId = '000000000000000';
16+
const username = '[email protected]';
17+
const testPath = '/lightning/whatever';
18+
const testInstance = 'https://cs1.my.salesforce.com';
19+
const accessToken = 'testAccessToken';
20+
const expectedDefaultUrl = `${testInstance}/secur/frontdoor.jsp?sid=${accessToken}`;
21+
const expectedUrl = `${expectedDefaultUrl}&retURL=${encodeURIComponent(testPath)}`;
22+
23+
const testJsonStructure = (response: object) => {
24+
expect(response).to.have.property('url');
25+
expect(response).to.have.property('username').equal(username);
26+
expect(response).to.have.property('orgId').equal(orgId);
27+
return true;
28+
};
29+
30+
describe('open commands', () => {
31+
const spies = new Map();
32+
afterEach(() => spies.clear());
33+
34+
beforeEach(async function () {
35+
$$.SANDBOX.restore();
36+
stubMethod($$.SANDBOX, Org, 'create').resolves(Org.prototype);
37+
stubMethod($$.SANDBOX, Org.prototype, 'getField').withArgs(Org.Fields.INSTANCE_URL).returns(testInstance);
38+
stubMethod($$.SANDBOX, Org.prototype, 'refreshAuth').resolves({});
39+
stubMethod($$.SANDBOX, Org.prototype, 'getOrgId').returns(orgId);
40+
stubMethod($$.SANDBOX, Org.prototype, 'getUsername').returns(username);
41+
stubMethod($$.SANDBOX, Org.prototype, 'getConnection').returns({
42+
accessToken,
43+
});
44+
spies.set('open', stubMethod($$.SANDBOX, utils, 'openUrl').resolves());
45+
});
46+
47+
describe('url generation', () => {
48+
test
49+
.stdout()
50+
.command(['force:org:open', '--json', '--targetusername', username, '--urlonly'])
51+
.it('org without a url defaults to proper default', (ctx) => {
52+
const response = JSON.parse(ctx.stdout);
53+
expect(response.status).to.equal(0);
54+
expect(testJsonStructure(response.result)).to.be.true;
55+
expect(response.result.url).to.equal(expectedDefaultUrl);
56+
});
57+
58+
test
59+
.stdout()
60+
.command(['force:org:open', '--json', '--targetusername', username, '--urlonly', '--path', testPath])
61+
.it('org with a url is built correctly', (ctx) => {
62+
const response = JSON.parse(ctx.stdout);
63+
expect(response.status).to.equal(0);
64+
expect(testJsonStructure(response.result)).to.be.true;
65+
expect(response.result.url).to.equal(expectedUrl);
66+
});
67+
68+
test
69+
.do(() => {
70+
process.env.FORCE_OPEN_URL = testPath;
71+
})
72+
.finally(() => {
73+
delete process.env.FORCE_OPEN_URL;
74+
})
75+
.stdout()
76+
.command(['force:org:open', '--json', '--targetusername', username, '--urlonly'])
77+
.it('can read url from env', (ctx) => {
78+
const response = JSON.parse(ctx.stdout);
79+
expect(response.status).to.equal(0);
80+
expect(testJsonStructure(response.result)).to.be.true;
81+
expect(response.result.url).to.equal(expectedUrl);
82+
});
83+
});
84+
85+
describe('domain resolution, with callout', () => {
86+
beforeEach(() => {
87+
stubMethod($$.SANDBOX, MyDomainResolver, 'create').resolves(MyDomainResolver.prototype);
88+
});
89+
test
90+
.do(() => {
91+
spies.set('resolver', stubMethod($$.SANDBOX, MyDomainResolver.prototype, 'resolve').resolves('1.1.1.1'));
92+
})
93+
.stdout()
94+
.command(['force:org:open', '--json', '--targetusername', username, '--path', testPath])
95+
.it('waits on domains that need time to resolve', (ctx) => {
96+
const response = JSON.parse(ctx.stdout);
97+
expect(response.status).to.equal(0);
98+
expect(testJsonStructure(response.result)).to.be.true;
99+
expect(response.result.url).to.equal(expectedUrl);
100+
101+
expect(spies.get('resolver').callCount).to.equal(1);
102+
});
103+
104+
test
105+
.do(() => {
106+
spies.set('resolver', stubMethod($$.SANDBOX, MyDomainResolver.prototype, 'resolve').rejects());
107+
})
108+
.stdout()
109+
.command(['force:org:open', '--json', '--targetusername', username, '--path', testPath])
110+
.it('handles domain timeouts', (ctx) => {
111+
const response = JSON.parse(ctx.stdout);
112+
expect(spies.get('resolver').callCount).to.equal(1);
113+
expect(spies.get('open').callCount).to.equal(0);
114+
expect(response.status).to.equal(1);
115+
expect(response.message).to.equal(messages.getMessage('domainTimeoutError'));
116+
});
117+
});
118+
119+
describe('domain resolution, no callout', () => {
120+
beforeEach(() => {
121+
stubMethod($$.SANDBOX, MyDomainResolver, 'create').resolves(MyDomainResolver.prototype);
122+
spies.set('resolver', stubMethod($$.SANDBOX, MyDomainResolver.prototype, 'resolve').resolves('1.1.1.1'));
123+
});
124+
it('does not wait for domains on internal urls');
125+
126+
test
127+
.do(() => {
128+
process.env.SFDX_CONTAINER_MODE = 'true';
129+
})
130+
.finally(() => {
131+
delete process.env.SFDX_CONTAINER_MODE;
132+
})
133+
.stdout()
134+
.command(['force:org:open', '--json', '--targetusername', username, '--path', testPath])
135+
.it('does not wait for domains in container mode, even without urlonly', (ctx) => {
136+
const response = JSON.parse(ctx.stdout);
137+
expect(response.status).to.equal(0);
138+
expect(testJsonStructure(response.result)).to.be.true;
139+
expect(response.result.url).to.equal(expectedUrl);
140+
141+
expect(spies.get('resolver').callCount).to.equal(0);
142+
});
143+
144+
test
145+
.do(() => {
146+
process.env.SFDX_DOMAIN_RETRY = '0';
147+
})
148+
.finally(() => {
149+
delete process.env.SFDX_DOMAIN_RETRY;
150+
})
151+
.stdout()
152+
.command(['force:org:open', '--json', '--targetusername', username, '--path', testPath])
153+
.it('does not wait for domains when timeouts are zero, even without urlonly', (ctx) => {
154+
const response = JSON.parse(ctx.stdout);
155+
expect(response.status).to.equal(0);
156+
expect(testJsonStructure(response.result)).to.be.true;
157+
expect(spies.get('resolver').callCount).to.equal(0);
158+
});
159+
});
160+
161+
describe('human output', () => {
162+
test
163+
.do(() => {
164+
spies.set('resolver', stubMethod($$.SANDBOX, MyDomainResolver.prototype, 'resolve').resolves('1.1.1.1'));
165+
})
166+
.stdout()
167+
.command(['force:org:open', '--targetusername', username, '--path', testPath])
168+
.it('calls open and outputs proper success message', (ctx) => {
169+
expect(ctx.stdout).to.include(messages.getMessage('humanSuccess', [orgId, username, expectedUrl]));
170+
expect(spies.get('resolver').callCount).to.equal(1);
171+
expect(spies.get('open').callCount).to.equal(1);
172+
});
173+
174+
test
175+
.do(() => {
176+
spies.set('resolver', stubMethod($$.SANDBOX, MyDomainResolver.prototype, 'resolve').rejects());
177+
})
178+
.stderr()
179+
.command(['force:org:open', '--targetusername', username, '--path', testPath])
180+
.it('throws on dns fail', (ctx) => {
181+
expect(ctx.stderr).to.contain(messages.getMessage('domainTimeoutError'));
182+
expect(spies.get('resolver').callCount).to.equal(1);
183+
expect(spies.get('open').callCount).to.equal(0);
184+
});
185+
});
186+
});

0 commit comments

Comments
 (0)