Skip to content

Commit bfc34ef

Browse files
Merge pull request #1120 from salesforcecli/mdonnalley/ink
feat: @oclif/multi-stage-output
2 parents 3499c53 + 85d306a commit bfc34ef

File tree

11 files changed

+371
-410
lines changed

11 files changed

+371
-410
lines changed

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@
66
"bugs": "https://github.com/forcedotcom/cli/issues",
77
"dependencies": {
88
"@oclif/core": "^4.0.16",
9+
"@oclif/multi-stage-output": "^0.3.0",
910
"@salesforce/core": "^8.4.0",
1011
"@salesforce/kit": "^3.2.0",
1112
"@salesforce/sf-plugins-core": "^11.3.3",
1213
"@salesforce/source-deploy-retrieve": "^12.1.12",
1314
"ansis": "^3.2.0",
1415
"change-case": "^5.4.4",
1516
"is-wsl": "^3.1.0",
16-
"open": "^10.1.0"
17+
"open": "^10.1.0",
18+
"terminal-link": "^3.0.0"
1719
},
1820
"devDependencies": {
1921
"@oclif/plugin-command-snapshot": "^5.2.12",

src/commands/org/create/scratch.ts

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,28 @@
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 { MultiStageOutput } from '@oclif/multi-stage-output';
89
import {
910
Lifecycle,
1011
Messages,
1112
Org,
1213
scratchOrgCreate,
1314
ScratchOrgLifecycleEvent,
1415
scratchOrgLifecycleEventName,
16+
scratchOrgLifecycleStages,
1517
SfError,
1618
} from '@salesforce/core';
1719
import { Flags, SfCommand } from '@salesforce/sf-plugins-core';
1820
import { Duration } from '@salesforce/kit';
21+
import terminalLink from 'terminal-link';
1922
import { buildScratchOrgRequest } from '../../../shared/scratchOrgRequest.js';
20-
import { buildStatus } from '../../../shared/scratchOrgOutput.js';
2123
import { ScratchCreateResponse } from '../../../shared/orgTypes.js';
2224

2325
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
2426
const messages = Messages.loadMessages('@salesforce/plugin-org', 'create_scratch');
2527

2628
const definitionFileHelpGroupName = 'Definition File Override';
29+
2730
export default class OrgCreateScratch extends SfCommand<ScratchCreateResponse> {
2831
public static readonly summary = messages.getMessage('summary');
2932
public static readonly description = messages.getMessage('description');
@@ -167,38 +170,69 @@ export default class OrgCreateScratch extends SfCommand<ScratchCreateResponse> {
167170
flags,
168171
flags['client-id'] ? await this.secretPrompt({ message: messages.getMessage('prompt.secret') }) : undefined
169172
);
170-
let lastStatus: string | undefined;
171173

172-
if (!flags.async) {
173-
lifecycle.on<ScratchOrgLifecycleEvent>(scratchOrgLifecycleEventName, async (data): Promise<void> => {
174-
lastStatus = buildStatus(data, baseUrl);
175-
this.spinner.status = lastStatus;
176-
return Promise.resolve();
177-
});
178-
}
179-
this.log();
180-
this.spinner.start(
181-
flags.async ? 'Requesting Scratch Org (will not wait for completion because --async)' : 'Creating Scratch Org'
182-
);
174+
const stager = new MultiStageOutput<ScratchOrgLifecycleEvent & { alias: string | undefined }>({
175+
stages: flags.async ? ['prepare request', 'send request', 'done'] : scratchOrgLifecycleStages,
176+
title: flags.async ? 'Creating Scratch Org (async)' : 'Creating Scratch Org',
177+
data: { alias: flags.alias },
178+
jsonEnabled: this.jsonEnabled(),
179+
postStagesBlock: [
180+
{
181+
label: 'Request Id',
182+
type: 'dynamic-key-value',
183+
get: (data) =>
184+
data?.scratchOrgInfo?.Id && terminalLink(data.scratchOrgInfo.Id, `${baseUrl}/${data.scratchOrgInfo.Id}`),
185+
bold: true,
186+
},
187+
{
188+
label: 'OrgId',
189+
type: 'dynamic-key-value',
190+
get: (data) => data?.scratchOrgInfo?.ScratchOrg,
191+
bold: true,
192+
color: 'cyan',
193+
},
194+
{
195+
label: 'Username',
196+
type: 'dynamic-key-value',
197+
get: (data) => data?.scratchOrgInfo?.SignupUsername,
198+
bold: true,
199+
color: 'cyan',
200+
},
201+
{
202+
label: 'Alias',
203+
type: 'static-key-value',
204+
get: (data) => data?.alias,
205+
},
206+
],
207+
});
208+
209+
lifecycle.on<ScratchOrgLifecycleEvent>(scratchOrgLifecycleEventName, async (data): Promise<void> => {
210+
stager.goto(data.stage, data);
211+
if (data.stage === 'done') {
212+
stager.stop();
213+
}
214+
return Promise.resolve();
215+
});
183216

184217
try {
185218
const { username, scratchOrgInfo, authFields, warnings } = await scratchOrgCreate(createCommandOptions);
186219

187-
this.spinner.stop(lastStatus);
188220
if (!scratchOrgInfo) {
189221
throw new SfError('The scratch org did not return with any information');
190222
}
191-
this.log();
223+
192224
if (flags.async) {
225+
stager.goto('done', { scratchOrgInfo });
226+
stager.stop();
193227
this.info(messages.getMessage('action.resume', [this.config.bin, scratchOrgInfo.Id]));
194228
} else {
195229
this.logSuccess(messages.getMessage('success'));
196230
}
197231

198232
return { username, scratchOrgInfo, authFields, warnings, orgId: authFields?.orgId };
199233
} catch (error) {
234+
stager.stop(error as Error);
200235
if (error instanceof SfError && error.name === 'ScratchOrgInfoTimeoutError') {
201-
this.spinner.stop(lastStatus);
202236
const scratchOrgInfoId = (error.data as { scratchOrgInfoId: string }).scratchOrgInfoId;
203237
const resumeMessage = messages.getMessage('action.resume', [this.config.bin, scratchOrgInfoId]);
204238

src/commands/org/delete/sandbox.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const messages = Messages.loadMessages('@salesforce/plugin-org', 'delete_sandbox
1515
export type SandboxDeleteResponse = {
1616
orgId: string;
1717
username: string;
18-
}
18+
};
1919

2020
export default class DeleteSandbox extends SfCommand<SandboxDeleteResponse> {
2121
public static readonly summary = messages.getMessage('summary');

src/commands/org/delete/scratch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const messages = Messages.loadMessages('@salesforce/plugin-org', 'delete_scratch
1515
export type ScratchDeleteResponse = {
1616
orgId: string;
1717
username: string;
18-
}
18+
};
1919

2020
export default class DeleteScratch extends SfCommand<ScratchDeleteResponse> {
2121
public static readonly summary = messages.getMessage('summary');

src/commands/org/resume/scratch.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ import {
1414
ScratchOrgCache,
1515
ScratchOrgLifecycleEvent,
1616
scratchOrgLifecycleEventName,
17+
scratchOrgLifecycleStages,
1718
scratchOrgResume,
1819
SfError,
1920
} from '@salesforce/core';
21+
import terminalLink from 'terminal-link';
22+
import { MultiStageOutput } from '@oclif/multi-stage-output';
2023
import { ScratchCreateResponse } from '../../../shared/orgTypes.js';
21-
import { buildStatus } from '../../../shared/scratchOrgOutput.js';
2224

2325
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
2426
const messages = Messages.loadMessages('@salesforce/plugin-org', 'resume_scratch');
@@ -55,26 +57,60 @@ export default class OrgResumeScratch extends SfCommand<ScratchCreateResponse> {
5557

5658
// oclif doesn't know that the exactlyOne flag will ensure that one of these is set, and there we definitely have a jobID.
5759
assert(jobId);
58-
const hubBaseUrl = cache.get(jobId)?.hubBaseUrl;
59-
let lastStatus: string | undefined;
60+
const cached = cache.get(jobId);
61+
const hubBaseUrl = cached?.hubBaseUrl;
62+
63+
const stager = new MultiStageOutput<ScratchOrgLifecycleEvent & { alias: string | undefined }>({
64+
stages: scratchOrgLifecycleStages,
65+
title: 'Resuming Scratch Org',
66+
data: { alias: cached?.alias },
67+
jsonEnabled: this.jsonEnabled(),
68+
postStagesBlock: [
69+
{
70+
label: 'Request Id',
71+
type: 'dynamic-key-value',
72+
get: (data) =>
73+
data?.scratchOrgInfo?.Id && terminalLink(data.scratchOrgInfo.Id, `${hubBaseUrl}/${data.scratchOrgInfo.Id}`),
74+
bold: true,
75+
},
76+
{
77+
label: 'OrgId',
78+
type: 'dynamic-key-value',
79+
get: (data) => data?.scratchOrgInfo?.ScratchOrg,
80+
bold: true,
81+
color: 'cyan',
82+
},
83+
{
84+
label: 'Username',
85+
type: 'dynamic-key-value',
86+
get: (data) => data?.scratchOrgInfo?.SignupUsername,
87+
bold: true,
88+
color: 'cyan',
89+
},
90+
{
91+
label: 'Alias',
92+
type: 'static-key-value',
93+
get: (data) => data?.alias,
94+
},
95+
],
96+
});
6097

6198
lifecycle.on<ScratchOrgLifecycleEvent>(scratchOrgLifecycleEventName, async (data): Promise<void> => {
62-
lastStatus = buildStatus(data, hubBaseUrl);
63-
this.spinner.status = lastStatus;
99+
stager.goto(data.stage, data);
100+
if (data.stage === 'done') {
101+
stager.stop();
102+
}
64103
return Promise.resolve();
65104
});
66105

67-
this.log();
68-
this.spinner.start('Creating Scratch Org');
69-
70106
try {
71107
const { username, scratchOrgInfo, authFields, warnings } = await scratchOrgResume(jobId);
72-
this.spinner.stop(lastStatus);
73-
74108
this.log();
75109
this.logSuccess(messages.getMessage('success'));
76110
return { username, scratchOrgInfo, authFields, warnings, orgId: authFields?.orgId };
77111
} catch (e) {
112+
stager.stop(e as Error);
113+
78114
if (cache.keys() && e instanceof Error && e.name === 'CacheMissError') {
79115
// we have something in the cache, but it didn't match what the user passed in
80116
throw messages.createError('error.jobIdMismatch', [jobId]);

src/shared/orgHooks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ type PostOrgCreateOpts = HookOpts<OrgCreateResult>;
3434
*/
3535
export type OrgHooks = {
3636
postorgcreate: PostOrgCreateOpts;
37-
} & Interfaces.Hooks
37+
} & Interfaces.Hooks;
3838

3939
// eslint-disable-next-line @typescript-eslint/no-explicit-any
4040
export type OrgHook<T> = (this: Hook.Context, options: T extends keyof Interfaces.Hooks ? OrgHooks[T] : T) => any;

src/shared/scratchOrgOutput.ts

Lines changed: 0 additions & 41 deletions
This file was deleted.

test/shared/scratchOrgOutput.test.ts

Lines changed: 0 additions & 71 deletions
This file was deleted.

test/shared/scratchOrgRequest.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@ import { Config, Interfaces } from '@oclif/core';
88
import { expect } from 'chai';
99
import { MockTestOrgData, TestContext } from '@salesforce/core/testSetup';
1010
import { buildScratchOrgRequest } from '../../src/shared/scratchOrgRequest.js';
11-
import EnvCreateScratch from '../../src/commands/org/create/scratch.js';
11+
import OrgCreateScratch from '../../src/commands/org/create/scratch.js';
1212

13-
class Wrapper extends EnvCreateScratch {
13+
class Wrapper extends OrgCreateScratch {
1414
// simple method to return the parsed flags so they can be used in the tests
1515
public async getFlags(): Promise<Interfaces.InferredFlags<typeof Wrapper.flags>> {
1616
return (await this.parse(Wrapper)).flags;
1717
}
1818
}
1919

2020
/** pass in the params in array form, get back the parsed flags */
21-
const paramsToFlags = async (params: string[]): Promise<Interfaces.InferredFlags<typeof EnvCreateScratch.flags>> =>
21+
const paramsToFlags = async (params: string[]): Promise<Interfaces.InferredFlags<typeof OrgCreateScratch.flags>> =>
2222
new Wrapper(params, { runHook: () => ({ successes: [], failures: [] }) } as unknown as Config).getFlags();
2323

2424
describe('buildScratchOrgRequest function', () => {

test/unit/org/resumeScratch.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe('org:resume:scratch', () => {
3434
});
3535

3636
try {
37-
await OrgResumeScratch.run(['--job-id', '2SRFOOFOOFOOFOOFOO']);
37+
await OrgResumeScratch.run(['--job-id', '2SRFOOFOOFOOFOOFOO', '--json']);
3838
expect(false, 'ResumeSandbox should have thrown sandboxCreateNotComplete');
3939
} catch (err: unknown) {
4040
const error = err as SfError;
@@ -50,7 +50,7 @@ describe('org:resume:scratch', () => {
5050
});
5151

5252
try {
53-
await OrgResumeScratch.run(['--job-id', '2SRFOOFOOFOOFOOFOO']);
53+
await OrgResumeScratch.run(['--job-id', '2SRFOOFOOFOOFOOFOO', '--json']);
5454
expect(false, 'ResumeSandbox should have thrown sandboxCreateNotComplete');
5555
} catch (err: unknown) {
5656
const error = err as SfError;

0 commit comments

Comments
 (0)