Skip to content

Commit bc6fd8c

Browse files
committed
feat(@clack/prompts): add prompt workflow
1 parent 5529c89 commit bc6fd8c

File tree

4 files changed

+275
-15
lines changed

4 files changed

+275
-15
lines changed

examples/basic/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"stream": "jiti ./stream.ts",
1414
"spinner": "jiti ./spinner.ts",
1515
"spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts",
16-
"spinner-timer": "jiti ./spinner-timer.ts"
16+
"spinner-timer": "jiti ./spinner-timer.ts",
17+
"workflow": "jiti ./workflow.ts"
1718
},
1819
"devDependencies": {
1920
"jiti": "^1.17.0"

examples/basic/workflow.ts

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import * as p from '@clack/prompts';
2+
3+
(async () => {
4+
const results = await p
5+
.workflow()
6+
.step('name', () => p.text({ message: 'What is your package name?' }))
7+
.step('type', () =>
8+
p.select({
9+
message: 'Pick a project type:',
10+
initialValue: 'ts',
11+
maxItems: 5,
12+
options: [
13+
{ value: 'ts', label: 'TypeScript' },
14+
{ value: 'js', label: 'JavaScript' },
15+
{ value: 'rust', label: 'Rust' },
16+
{ value: 'go', label: 'Go' },
17+
{ value: 'python', label: 'Python' },
18+
{ value: 'coffee', label: 'CoffeeScript', hint: 'oh no' },
19+
],
20+
})
21+
)
22+
.step('install', () =>
23+
p.confirm({
24+
message: 'Install dependencies?',
25+
initialValue: false,
26+
})
27+
)
28+
.forkStep(
29+
'fork',
30+
({ results }) => results.install,
31+
({ results }) => {
32+
return p.workflow().step('package', () =>
33+
p.select({
34+
message: 'Pick a package manager:',
35+
initialValue: 'pnpm',
36+
options: [
37+
{
38+
label: 'npm',
39+
value: 'npm',
40+
},
41+
{
42+
label: 'yarn',
43+
value: 'yarn',
44+
},
45+
{
46+
label: 'pnpm',
47+
value: 'pnpm',
48+
},
49+
],
50+
})
51+
);
52+
}
53+
)
54+
.run();
55+
56+
await p
57+
.workflow()
58+
.step('cancel', () => p.text({ message: 'Try cancel prompt (Ctrl + C):' }))
59+
.step('afterCancel', () => p.text({ message: 'This will not appear!' }))
60+
.onCancel(({ results }) => {
61+
p.cancel('Workflow canceled');
62+
process.exit(0);
63+
})
64+
.run();
65+
})();

packages/prompts/README.md

+66
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,72 @@ const group = await p.group(
159159
console.log(group.name, group.age, group.color);
160160
```
161161

162+
### Workflow
163+
164+
Works just like `group` but infer types way better and treats your group like a workflow, allowing you to create conditional steps (forks) along the process.
165+
166+
```js
167+
import * as p from '@clack/prompts';
168+
169+
const results = await p
170+
.workflow()
171+
.step('name', () => p.text({ message: 'What is your package name?' }))
172+
.step('type', () =>
173+
p.select({
174+
message: `Pick a project type:`,
175+
initialValue: 'ts',
176+
maxItems: 5,
177+
options: [
178+
{ value: 'ts', label: 'TypeScript' },
179+
{ value: 'js', label: 'JavaScript' },
180+
{ value: 'rust', label: 'Rust' },
181+
{ value: 'go', label: 'Go' },
182+
{ value: 'python', label: 'Python' },
183+
{ value: 'coffee', label: 'CoffeeScript', hint: 'oh no' },
184+
],
185+
})
186+
)
187+
.step('install', () =>
188+
p.confirm({
189+
message: 'Install dependencies?',
190+
initialValue: false,
191+
})
192+
)
193+
.step('fork', ({ results }) => {
194+
if (results.install === true) {
195+
return p
196+
.workflow()
197+
.step('package', () =>
198+
p.select({
199+
message: 'Pick a package manager:',
200+
initialValue: 'pnpm',
201+
options: [
202+
{
203+
label: 'npm',
204+
value: 'npm',
205+
},
206+
{
207+
label: 'yarn',
208+
value: 'yarn',
209+
},
210+
{
211+
label: 'pnpm',
212+
value: 'pnpm',
213+
},
214+
],
215+
})
216+
)
217+
.run();
218+
}
219+
})
220+
.onCancel(() => {
221+
p.cancel('Workflow canceled');
222+
process.exit(0);
223+
})
224+
.run();
225+
console.log(results);
226+
```
227+
162228
### Tasks
163229

164230
Execute multiple tasks in spinners.

packages/prompts/src/index.ts

+142-14
Original file line numberDiff line numberDiff line change
@@ -844,36 +844,49 @@ export const spinner = ({ indicator = 'dots' }: SpinnerOptions = {}) => {
844844
};
845845
};
846846

847-
export type PromptGroupAwaitedReturn<T> = {
848-
[P in keyof T]: Exclude<Awaited<T[P]>, symbol>;
847+
type Prettify<T> = {
848+
[P in keyof T]: T[P];
849+
} & {};
850+
851+
export type PromptAwaitedReturn<T> = Exclude<Awaited<T>, symbol>;
852+
853+
export type PromptGroupAwaitedReturn<T> = Prettify<{
854+
[P in keyof T]: PromptAwaitedReturn<T[P]>;
855+
}>;
856+
857+
export type PromptWithOptions<
858+
TResults,
859+
TResult,
860+
// biome-ignore lint/complexity/noBannedTypes: {} is initializing a empty object
861+
TOptions extends Record<string, unknown> = {},
862+
> = (
863+
opts: Prettify<
864+
{
865+
results: PromptGroupAwaitedReturn<TResults>;
866+
} & TOptions
867+
>
868+
) => TResult;
869+
870+
export type PromptGroup<T> = {
871+
[P in keyof T]: PromptWithOptions<Partial<Omit<T, P>>, undefined | Promise<T[P] | undefined>>;
849872
};
850873

851874
export interface PromptGroupOptions<T> {
852875
/**
853876
* Control how the group can be canceled
854877
* if one of the prompts is canceled.
855878
*/
856-
onCancel?: (opts: { results: Prettify<Partial<PromptGroupAwaitedReturn<T>>> }) => void;
879+
onCancel?: PromptWithOptions<Partial<T>, void>;
857880
}
858881

859-
type Prettify<T> = {
860-
[P in keyof T]: T[P];
861-
} & {};
862-
863-
export type PromptGroup<T> = {
864-
[P in keyof T]: (opts: {
865-
results: Prettify<Partial<PromptGroupAwaitedReturn<Omit<T, P>>>>;
866-
}) => undefined | Promise<T[P] | undefined>;
867-
};
868-
869882
/**
870883
* Define a group of prompts to be displayed
871884
* and return a results of objects within the group
872885
*/
873886
export const group = async <T>(
874887
prompts: PromptGroup<T>,
875888
opts?: PromptGroupOptions<T>
876-
): Promise<Prettify<PromptGroupAwaitedReturn<T>>> => {
889+
): Promise<PromptGroupAwaitedReturn<T>> => {
877890
const results = {} as any;
878891
const promptNames = Object.keys(prompts);
879892

@@ -898,6 +911,121 @@ export const group = async <T>(
898911
return results;
899912
};
900913

914+
type NextWorkflowBuilder<
915+
TResults extends Record<string, unknown>,
916+
TKey extends string,
917+
TResult,
918+
> = WorkflowBuilder<
919+
Prettify<
920+
{
921+
[Key in keyof TResults]: Key extends TKey ? TResult : TResults[Key];
922+
} & {
923+
[Key in TKey as undefined extends TResult ? never : TKey]: TResult;
924+
} & {
925+
[Key in TKey as undefined extends TResult ? TKey : never]?: TResult;
926+
}
927+
>
928+
>;
929+
930+
type WorkflowStep<TName extends string, TResults, TResult = unknown> = {
931+
name: TName;
932+
prompt: PromptWithOptions<TResults, TResult>;
933+
setResult: boolean;
934+
condition?: PromptWithOptions<TResults, boolean>;
935+
};
936+
937+
// biome-ignore lint/complexity/noBannedTypes: <explanation>
938+
class WorkflowBuilder<TResults extends Record<string, unknown> = {}> {
939+
private results: TResults = {} as TResults;
940+
private steps: WorkflowStep<string, TResults>[] = [];
941+
private cancelCallback: PromptWithOptions<Partial<TResults>, void> | undefined;
942+
943+
public step<TName extends string, TResult>(
944+
name: TName extends keyof TResults ? never : TName,
945+
prompt: PromptWithOptions<TResults, TResult>
946+
): NextWorkflowBuilder<TResults, TName, PromptAwaitedReturn<TResult>> {
947+
this.steps.push({ name, prompt, setResult: true });
948+
return this as any;
949+
}
950+
951+
public conditionalStep<TName extends string, TResult>(
952+
name: TName,
953+
condition: PromptWithOptions<TResults, boolean>,
954+
prompt: PromptWithOptions<TResults, TResult>
955+
): NextWorkflowBuilder<
956+
TResults,
957+
TName,
958+
| (TName extends keyof TResults ? TResults[TName] : never)
959+
| PromptAwaitedReturn<TResult>
960+
| undefined
961+
> {
962+
this.steps.push({ name, prompt, condition, setResult: true });
963+
return this as any;
964+
}
965+
966+
public forkStep<TName extends string, TResult extends Record<string, unknown>>(
967+
name: TName,
968+
condition: PromptWithOptions<TResults, boolean>,
969+
subWorkflow: PromptWithOptions<TResults, WorkflowBuilder<TResult>>
970+
): NextWorkflowBuilder<
971+
TResults,
972+
TName,
973+
(TName extends keyof TResults ? TResults[TName] : never) | TResult | undefined
974+
> {
975+
this.steps.push({
976+
name,
977+
prompt: ({ results }) => {
978+
return subWorkflow({ results }).run();
979+
},
980+
condition,
981+
setResult: true,
982+
});
983+
return this as any;
984+
}
985+
986+
public logStep(
987+
name: string,
988+
prompt: PromptWithOptions<TResults, void>
989+
): WorkflowBuilder<TResults> {
990+
this.steps.push({ name, prompt, setResult: false });
991+
return this;
992+
}
993+
994+
public customStep<TName extends string, TResult>(
995+
step: WorkflowStep<TName, TResults, TResult>
996+
): NextWorkflowBuilder<TResults, TName, PromptAwaitedReturn<TResult>> {
997+
this.steps.push(step);
998+
return this as any;
999+
}
1000+
1001+
public onCancel(cb: PromptWithOptions<Partial<TResults>, void>): WorkflowBuilder<TResults> {
1002+
this.cancelCallback = cb;
1003+
return this;
1004+
}
1005+
1006+
public async run(): Promise<TResults> {
1007+
for (const step of this.steps) {
1008+
if (step.condition && !step.condition({ results: this.results as any })) {
1009+
continue;
1010+
}
1011+
const result = await step.prompt({ results: this.results as any });
1012+
if (isCancel(result)) {
1013+
this.cancelCallback?.({ results: this.results as any });
1014+
continue;
1015+
}
1016+
if (step.setResult) {
1017+
//@ts-ignore
1018+
this.results[step.name] = result;
1019+
}
1020+
}
1021+
return this.results;
1022+
}
1023+
}
1024+
1025+
export const workflow = () => {
1026+
return new WorkflowBuilder();
1027+
};
1028+
9011029
export type Task = {
9021030
/**
9031031
* Task title

0 commit comments

Comments
 (0)