Skip to content

Commit 79f6766

Browse files
fix: allow expired orgs to be deleted
1 parent 2c8bbbb commit 79f6766

File tree

6 files changed

+107
-46
lines changed

6 files changed

+107
-46
lines changed

messages/delete.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ The force:org:delete command is deprecated. Use org:delete:scratch or org:delete
88

99
# description
1010

11-
Salesforce CLI marks the org for deletion in either the Dev Hub org (for scratch orgs) or production org (for sandboxes) and then deletes all local references to the org from your computer.
11+
Salesforce CLI marks the org for deletion in either the Dev Hub org (for scratch orgs) or production org (for sandboxes)
12+
and then deletes all local references to the org from your computer.
1213

1314
To mark the org for deletion without being prompted to confirm, specify --noprompt.
1415

@@ -22,6 +23,14 @@ To mark the org for deletion without being prompted to confirm, specify --noprom
2223

2324
No prompt to confirm deletion.
2425

26+
# missingUsername
27+
28+
Unable to determine the username of the org to delete. Specify the username with the --target-org | -o flag.
29+
30+
# flags.target-org.summary
31+
32+
Username or alias of the target org.
33+
2534
# flags.targetdevhubusername
2635

2736
The targetdevhubusername flag exists only for backwards compatibility. It is not necessary and has no effect.
@@ -48,4 +57,5 @@ Successfully marked scratch org %s for deletion
4857

4958
# commandSandboxSuccess
5059

51-
The sandbox org %s has been successfully removed from your list of CLI authorized orgs. If you created the sandbox with one of the force:org commands, it has also been marked for deletion.
60+
The sandbox org %s has been successfully removed from your list of CLI authorized orgs. If you created the sandbox with
61+
one of the force:org commands, it has also been marked for deletion.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,4 +236,4 @@
236236
"output": []
237237
}
238238
}
239-
}
239+
}

src/commands/force/org/delete.ts

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +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 {
8-
Flags,
9-
SfCommand,
10-
requiredOrgFlagWithDeprecations,
11-
orgApiVersionFlagWithDeprecations,
12-
loglevel,
13-
} from '@salesforce/sf-plugins-core';
14-
import { Messages } from '@salesforce/core';
7+
import { Flags, SfCommand, orgApiVersionFlagWithDeprecations, loglevel } from '@salesforce/sf-plugins-core';
8+
import { AuthInfo, AuthRemover, Messages, Org, StateAggregator } from '@salesforce/core';
9+
import { SandboxAccessor } from '@salesforce/core/lib/stateAggregator/accessors/sandboxAccessor';
1510

1611
Messages.importMessagesDirectory(__dirname);
1712
const messages = Messages.loadMessages('@salesforce/plugin-org', 'delete');
@@ -30,7 +25,15 @@ export class Delete extends SfCommand<DeleteResult> {
3025
message: messages.getMessage('deprecation'),
3126
};
3227
public static readonly flags = {
33-
'target-org': requiredOrgFlagWithDeprecations,
28+
'target-org': Flags.string({
29+
// not required because the user could be assuming the default config
30+
aliases: ['targetusername', 'u'],
31+
deprecateAliases: true,
32+
// we're recreating the flag without all the validation
33+
// eslint-disable-next-line sf-plugin/dash-o
34+
char: 'o',
35+
summary: messages.getMessage('flags.target-org.summary'),
36+
}),
3437
targetdevhubusername: Flags.string({
3538
summary: messages.getMessage('flags.targetdevhubusername'),
3639
char: 'v',
@@ -52,24 +55,38 @@ export class Delete extends SfCommand<DeleteResult> {
5255

5356
public async run(): Promise<DeleteResult> {
5457
const { flags } = await this.parse(Delete);
55-
const username = flags['target-org'].getUsername() ?? 'unknown username';
56-
const orgId = flags['target-org'].getOrgId();
57-
// the connection version can be set before using it to isSandbox and delete
58-
flags['target-org'].getConnection(flags['api-version']);
59-
const isSandbox = await flags['target-org'].isSandbox();
58+
const resolvedUsername =
59+
// from -o alias -> -o username -> [default username]
60+
(await StateAggregator.getInstance()).aliases.getUsername(flags['target-org'] ?? '') ??
61+
flags['target-org'] ??
62+
(this.configAggregator.getPropertyValue('target-org') as string);
63+
64+
if (!resolvedUsername) {
65+
throw messages.createError('missingUsername');
66+
}
67+
68+
const isSandbox = (await SandboxAccessor.create({ username: resolvedUsername })).has(resolvedUsername);
69+
const orgId = (await AuthInfo.create({ username: resolvedUsername })).getFields().orgId as string;
6070
// read the config file for the org to be deleted, if it has a PROD_ORG_USERNAME entry, it's a sandbox
6171
// we either need permission to proceed without a prompt OR get the user to confirm
6272
if (
6373
flags['no-prompt'] ||
64-
(await this.confirm(messages.getMessage('confirmDelete', [isSandbox ? 'sandbox' : 'scratch', username])))
74+
(await this.confirm(messages.getMessage('confirmDelete', [isSandbox ? 'sandbox' : 'scratch', resolvedUsername])))
6575
) {
6676
let alreadyDeleted = false;
6777
let successMessageKey = 'commandSandboxSuccess';
6878
try {
79+
const org = await Org.create({ aliasOrUsername: resolvedUsername });
80+
6981
// will determine if it's a scratch org or sandbox and will delete from the appropriate parent org (DevHub or Production)
70-
await flags['target-org'].delete();
82+
await org.delete();
7183
} catch (e) {
72-
if (e instanceof Error && e.name === 'ScratchOrgNotFound') {
84+
if (e instanceof Error && e.name === 'DomainNotFoundError') {
85+
// the org has expired, so remote operations won't work
86+
// let's clean up the files locally
87+
const authRemover = await AuthRemover.create();
88+
await authRemover.removeAuth(resolvedUsername);
89+
} else if (e instanceof Error && e.name === 'ScratchOrgNotFound') {
7390
alreadyDeleted = true;
7491
} else if (e instanceof Error && e.name === 'SandboxNotFound') {
7592
successMessageKey = 'sandboxConfigOnlySuccess';
@@ -80,12 +97,12 @@ export class Delete extends SfCommand<DeleteResult> {
8097

8198
this.log(
8299
isSandbox
83-
? messages.getMessage(successMessageKey, [username])
100+
? messages.getMessage(successMessageKey, [resolvedUsername])
84101
: messages.getMessage(alreadyDeleted ? 'deleteOrgConfigOnlyCommandSuccess' : 'deleteOrgCommandSuccess', [
85-
username,
102+
resolvedUsername,
86103
])
87104
);
88105
}
89-
return { username, orgId };
106+
return { username: resolvedUsername, orgId };
90107
}
91108
}

src/commands/org/delete/scratch.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
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 { Messages } from '@salesforce/core';
8+
import { AuthInfo, AuthRemover, Messages, Org, StateAggregator } from '@salesforce/core';
99
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
1010

1111
Messages.importMessagesDirectory(__dirname);
@@ -23,10 +23,14 @@ export default class EnvDeleteScratch extends SfCommand<ScratchDeleteResponse> {
2323
public static readonly aliases = ['env:delete:scratch'];
2424
public static readonly deprecateAliases = true;
2525
public static readonly flags = {
26-
'target-org': Flags.requiredOrg({
26+
'target-org': Flags.string({
27+
// not required because the user could be assuming the default config
28+
aliases: ['targetusername', 'u'],
29+
deprecateAliases: true,
30+
// we're recreating the flag without all the validation
31+
// eslint-disable-next-line sf-plugin/dash-o
2732
char: 'o',
2833
summary: messages.getMessage('flags.target-org.summary'),
29-
required: true,
3034
}),
3135
'no-prompt': Flags.boolean({
3236
char: 'p',
@@ -36,20 +40,35 @@ export default class EnvDeleteScratch extends SfCommand<ScratchDeleteResponse> {
3640

3741
public async run(): Promise<ScratchDeleteResponse> {
3842
const flags = (await this.parse(EnvDeleteScratch)).flags;
39-
const org = flags['target-org'];
43+
const resolvedUsername =
44+
// from -o alias -> -o username -> [default username]
45+
(await StateAggregator.getInstance()).aliases.getUsername(flags['target-org'] ?? '') ??
46+
flags['target-org'] ??
47+
(this.configAggregator.getPropertyValue('target-org') as string);
48+
const orgId = (await AuthInfo.create({ username: resolvedUsername })).getFields().orgId as string;
4049

41-
if (flags['no-prompt'] || (await this.confirm(messages.getMessage('prompt.confirm', [org.getUsername()])))) {
50+
if (flags['no-prompt'] || (await this.confirm(messages.getMessage('prompt.confirm', [resolvedUsername])))) {
4251
try {
52+
const org = await Org.create({ aliasOrUsername: resolvedUsername });
53+
4354
await org.delete();
4455
this.logSuccess(messages.getMessage('success', [org.getUsername()]));
56+
return { username: org.getUsername() as string, orgId: org.getOrgId() };
4557
} catch (e) {
46-
if (e instanceof Error && e.name === 'ScratchOrgNotFound') {
47-
this.logSuccess(messages.getMessage('success.Idempotent', [org.getUsername()]));
58+
if (e instanceof Error && e.name === 'DomainNotFoundError') {
59+
// the org has expired, so remote operations won't work
60+
// let's clean up the files locally
61+
// but first read the orgId from the auth file
62+
const authRemover = await AuthRemover.create();
63+
await authRemover.removeAuth(resolvedUsername);
64+
this.logSuccess(messages.getMessage('success', [resolvedUsername]));
65+
} else if (e instanceof Error && e.name === 'ScratchOrgNotFound') {
66+
this.logSuccess(messages.getMessage('success.Idempotent', [resolvedUsername]));
4867
} else {
4968
throw e;
5069
}
5170
}
5271
}
53-
return { username: org.getUsername() as string, orgId: org.getOrgId() };
72+
return { username: resolvedUsername, orgId };
5473
}
5574
}

test/nut/scratchDelete.nut.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,6 @@ describe('env:delete:scratch NUTs', () => {
5050
}
5151
});
5252

53-
it('should see default username in help', () => {
54-
const output = execCmd<ScratchDeleteResponse>('env:delete:scratch --help', { ensureExitCode: 0 }).shellOutput;
55-
expect(output).to.include(session.orgs.get('default')?.username);
56-
});
57-
5853
it('should delete the 1st scratch org by alias', () => {
5954
const command = `env:delete:scratch --target-org ${scratchOrgAlias} --no-prompt --json`;
6055
const output = execCmd<ScratchDeleteResponse>(command, { ensureExitCode: 0 }).jsonOutput?.result;

test/unit/force/org/delete.test.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
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 { Messages, Org, SfError } from '@salesforce/core';
7+
import { ConfigAggregator, Messages, Org, SfError } from '@salesforce/core';
88
import { MockTestOrgData, TestContext } from '@salesforce/core/lib/testSetup';
99

1010
import { expect } from 'chai';
1111
import { stubPrompter, stubSfCommandUx } from '@salesforce/sf-plugins-core';
12+
import { SandboxAccessor } from '@salesforce/core/lib/stateAggregator/accessors/sandboxAccessor';
13+
import { Config } from '@oclif/core';
1214
import { Delete } from '../../../../src/commands/force/org/delete';
1315

1416
Messages.importMessagesDirectory(__dirname);
@@ -30,8 +32,26 @@ describe('org:delete', () => {
3032
sfCommandUxStubs = stubSfCommandUx($$.SANDBOX);
3133
});
3234

35+
it('will throw an error when no default set', async () => {
36+
const deleteCommand = new Delete([], {} as Config);
37+
deleteCommand.configAggregator = await ConfigAggregator.create();
38+
$$.SANDBOX.stub(deleteCommand.configAggregator, 'getPropertyValue').onSecondCall().returns(undefined);
39+
40+
try {
41+
await deleteCommand.run();
42+
expect.fail('should have thrown an error');
43+
} catch (e) {
44+
const err = e as SfError;
45+
expect(err.name).to.equal('MissingUsernameError');
46+
expect(err.message).to.equal(messages.getMessage('missingUsername'));
47+
}
48+
});
49+
3350
it('will prompt before attempting to delete', async () => {
34-
const res = await Delete.run([]);
51+
const deleteCommand = new Delete([], {} as Config);
52+
deleteCommand.configAggregator = await ConfigAggregator.create();
53+
$$.SANDBOX.stub(deleteCommand.configAggregator, 'getPropertyValue').onSecondCall().returns(testOrg.username);
54+
const res = await deleteCommand.run();
3555
expect(prompterStubs.confirm.calledOnce).to.equal(true);
3656
expect(prompterStubs.confirm.firstCall.args[0]).to.equal(
3757
messages.getMessage('confirmDelete', ['scratch', testOrg.username])
@@ -40,8 +60,8 @@ describe('org:delete', () => {
4060
});
4161

4262
it('will determine sandbox vs scratch org and delete sandbox', async () => {
43-
$$.SANDBOX.stub(Org.prototype, 'isSandbox').resolves(true);
44-
const res = await Delete.run([]);
63+
$$.SANDBOX.stub(SandboxAccessor.prototype, 'has').resolves(true);
64+
const res = await Delete.run(['--target-org', testOrg.username]);
4565
expect(prompterStubs.confirm.calledOnce).to.equal(true);
4666
expect(prompterStubs.confirm.firstCall.args[0]).to.equal(
4767
messages.getMessage('confirmDelete', ['sandbox', testOrg.username])
@@ -52,7 +72,7 @@ describe('org:delete', () => {
5272
it('will NOT prompt before deleting scratch org when flag is provided', async () => {
5373
$$.SANDBOX.stub(Org.prototype, 'isSandbox').resolves(false);
5474
$$.SANDBOX.stub(Org.prototype, 'delete').resolves();
55-
const res = await Delete.run(['--noprompt']);
75+
const res = await Delete.run(['--noprompt', '--target-org', testOrg.username]);
5676
expect(prompterStubs.confirm.calledOnce).to.equal(false);
5777
expect(sfCommandUxStubs.log.callCount).to.equal(1);
5878
expect(sfCommandUxStubs.log.getCalls().flatMap((call) => call.args)).to.deep.include(
@@ -62,9 +82,9 @@ describe('org:delete', () => {
6282
});
6383

6484
it('will NOT prompt before deleting sandbox when flag is provided', async () => {
65-
$$.SANDBOX.stub(Org.prototype, 'isSandbox').resolves(true);
85+
$$.SANDBOX.stub(SandboxAccessor.prototype, 'has').resolves(true);
6686
$$.SANDBOX.stub(Org.prototype, 'delete').resolves();
67-
const res = await Delete.run(['--noprompt']);
87+
const res = await Delete.run(['--noprompt', '--target-org', testOrg.username]);
6888
expect(prompterStubs.confirm.calledOnce).to.equal(false);
6989
expect(sfCommandUxStubs.log.callCount).to.equal(1);
7090
expect(sfCommandUxStubs.log.getCalls().flatMap((call) => call.args)).to.deep.include(
@@ -76,7 +96,7 @@ describe('org:delete', () => {
7696
it('will catch the ScratchOrgNotFound and wrap correctly', async () => {
7797
$$.SANDBOX.stub(Org.prototype, 'isSandbox').resolves(false);
7898
$$.SANDBOX.stub(Org.prototype, 'delete').throws(new SfError('bah!', 'ScratchOrgNotFound'));
79-
const res = await Delete.run(['--noprompt']);
99+
const res = await Delete.run(['--noprompt', '--target-org', testOrg.username]);
80100
expect(prompterStubs.confirm.calledOnce).to.equal(false);
81101
expect(res).to.deep.equal({ orgId: testOrg.orgId, username: testOrg.username });
82102
expect(sfCommandUxStubs.log.getCalls().flatMap((call) => call.args)).to.deep.include(
@@ -85,9 +105,9 @@ describe('org:delete', () => {
85105
});
86106

87107
it('will catch the SandboxNotFound and wrap correctly', async () => {
88-
$$.SANDBOX.stub(Org.prototype, 'isSandbox').resolves(true);
108+
$$.SANDBOX.stub(SandboxAccessor.prototype, 'has').resolves(true);
89109
$$.SANDBOX.stub(Org.prototype, 'delete').throws(new SfError('bah!', 'SandboxNotFound'));
90-
const res = await Delete.run(['--noprompt']);
110+
const res = await Delete.run(['--noprompt', '--target-org', testOrg.username]);
91111
expect(prompterStubs.confirm.called).to.equal(false);
92112
expect(res).to.deep.equal({ orgId: testOrg.orgId, username: testOrg.username });
93113
expect(sfCommandUxStubs.log.getCalls().flatMap((call) => call.args)).to.deep.include(

0 commit comments

Comments
 (0)