Skip to content

feat(@clack/prompts): add prompt workflow #139

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion examples/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
"stream": "jiti ./stream.ts",
"spinner": "jiti ./spinner.ts",
"spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts",
"spinner-timer": "jiti ./spinner-timer.ts"
"spinner-timer": "jiti ./spinner-timer.ts",
"workflow": "jiti ./workflow.ts"
},
"devDependencies": {
"jiti": "^1.17.0"
Expand Down
65 changes: 65 additions & 0 deletions examples/basic/workflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import * as p from '@clack/prompts';

(async () => {
const results = await p
.workflow()
.step('name', () => p.text({ message: 'What is your package name?' }))
.step('type', () =>
p.select({
message: 'Pick a project type:',
initialValue: 'ts',
maxItems: 5,
options: [
{ value: 'ts', label: 'TypeScript' },
{ value: 'js', label: 'JavaScript' },
{ value: 'rust', label: 'Rust' },
{ value: 'go', label: 'Go' },
{ value: 'python', label: 'Python' },
{ value: 'coffee', label: 'CoffeeScript', hint: 'oh no' },
],
})
)
.step('install', () =>
p.confirm({
message: 'Install dependencies?',
initialValue: false,
})
)
.forkStep(
'fork',
({ results }) => results.install,
({ results }) => {
return p.workflow().step('package', () =>
p.select({
message: 'Pick a package manager:',
initialValue: 'pnpm',
options: [
{
label: 'npm',
value: 'npm',
},
{
label: 'yarn',
value: 'yarn',
},
{
label: 'pnpm',
value: 'pnpm',
},
],
})
);
}
)
.run();

await p
.workflow()
.step('cancel', () => p.text({ message: 'Try cancel prompt (Ctrl + C):' }))
.step('afterCancel', () => p.text({ message: 'This will not appear!' }))
.onCancel(({ results }) => {
p.cancel('Workflow canceled');
process.exit(0);
})
.run();
})();
66 changes: 66 additions & 0 deletions packages/prompts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,72 @@ const group = await p.group(
console.log(group.name, group.age, group.color);
```

### Workflow

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.

```js
import * as p from '@clack/prompts';

const results = await p
.workflow()
.step('name', () => p.text({ message: 'What is your package name?' }))
.step('type', () =>
p.select({
message: `Pick a project type:`,
initialValue: 'ts',
maxItems: 5,
options: [
{ value: 'ts', label: 'TypeScript' },
{ value: 'js', label: 'JavaScript' },
{ value: 'rust', label: 'Rust' },
{ value: 'go', label: 'Go' },
{ value: 'python', label: 'Python' },
{ value: 'coffee', label: 'CoffeeScript', hint: 'oh no' },
],
})
)
.step('install', () =>
p.confirm({
message: 'Install dependencies?',
initialValue: false,
})
)
.step('fork', ({ results }) => {
if (results.install === true) {
return p
.workflow()
.step('package', () =>
p.select({
message: 'Pick a package manager:',
initialValue: 'pnpm',
options: [
{
label: 'npm',
value: 'npm',
},
{
label: 'yarn',
value: 'yarn',
},
{
label: 'pnpm',
value: 'pnpm',
},
],
})
)
.run();
}
})
.onCancel(() => {
p.cancel('Workflow canceled');
process.exit(0);
})
.run();
console.log(results);
```

### Tasks

Execute multiple tasks in spinners.
Expand Down
156 changes: 142 additions & 14 deletions packages/prompts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -844,36 +844,49 @@ export const spinner = ({ indicator = 'dots' }: SpinnerOptions = {}) => {
};
};

export type PromptGroupAwaitedReturn<T> = {
[P in keyof T]: Exclude<Awaited<T[P]>, symbol>;
type Prettify<T> = {
[P in keyof T]: T[P];
} & {};

export type PromptAwaitedReturn<T> = Exclude<Awaited<T>, symbol>;

export type PromptGroupAwaitedReturn<T> = Prettify<{
[P in keyof T]: PromptAwaitedReturn<T[P]>;
}>;

export type PromptWithOptions<
TResults,
TResult,
// biome-ignore lint/complexity/noBannedTypes: {} is initializing a empty object
TOptions extends Record<string, unknown> = {},
> = (
opts: Prettify<
{
results: PromptGroupAwaitedReturn<TResults>;
} & TOptions
>
) => TResult;

export type PromptGroup<T> = {
[P in keyof T]: PromptWithOptions<Partial<Omit<T, P>>, undefined | Promise<T[P] | undefined>>;
};

export interface PromptGroupOptions<T> {
/**
* Control how the group can be canceled
* if one of the prompts is canceled.
*/
onCancel?: (opts: { results: Prettify<Partial<PromptGroupAwaitedReturn<T>>> }) => void;
onCancel?: PromptWithOptions<Partial<T>, void>;
}

type Prettify<T> = {
[P in keyof T]: T[P];
} & {};

export type PromptGroup<T> = {
[P in keyof T]: (opts: {
results: Prettify<Partial<PromptGroupAwaitedReturn<Omit<T, P>>>>;
}) => undefined | Promise<T[P] | undefined>;
};

/**
* Define a group of prompts to be displayed
* and return a results of objects within the group
*/
export const group = async <T>(
prompts: PromptGroup<T>,
opts?: PromptGroupOptions<T>
): Promise<Prettify<PromptGroupAwaitedReturn<T>>> => {
): Promise<PromptGroupAwaitedReturn<T>> => {
const results = {} as any;
const promptNames = Object.keys(prompts);

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

type NextWorkflowBuilder<
TResults extends Record<string, unknown>,
TKey extends string,
TResult,
> = WorkflowBuilder<
Prettify<
{
[Key in keyof TResults]: Key extends TKey ? TResult : TResults[Key];
} & {
[Key in TKey as undefined extends TResult ? never : TKey]: TResult;
} & {
[Key in TKey as undefined extends TResult ? TKey : never]?: TResult;
}
>
>;

type WorkflowStep<TName extends string, TResults, TResult = unknown> = {
name: TName;
prompt: PromptWithOptions<TResults, TResult>;
setResult: boolean;
condition?: PromptWithOptions<TResults, boolean>;
};

// biome-ignore lint/complexity/noBannedTypes: <explanation>
class WorkflowBuilder<TResults extends Record<string, unknown> = {}> {
private results: TResults = {} as TResults;
private steps: WorkflowStep<string, TResults>[] = [];
private cancelCallback: PromptWithOptions<Partial<TResults>, void> | undefined;

public step<TName extends string, TResult>(
name: TName extends keyof TResults ? never : TName,
prompt: PromptWithOptions<TResults, TResult>
): NextWorkflowBuilder<TResults, TName, PromptAwaitedReturn<TResult>> {
this.steps.push({ name, prompt, setResult: true });
return this as any;
}

public conditionalStep<TName extends string, TResult>(
name: TName,
condition: PromptWithOptions<TResults, boolean>,
prompt: PromptWithOptions<TResults, TResult>
): NextWorkflowBuilder<
TResults,
TName,
| (TName extends keyof TResults ? TResults[TName] : never)
| PromptAwaitedReturn<TResult>
| undefined
> {
this.steps.push({ name, prompt, condition, setResult: true });
return this as any;
}

public forkStep<TName extends string, TResult extends Record<string, unknown>>(
name: TName,
condition: PromptWithOptions<TResults, boolean>,
subWorkflow: PromptWithOptions<TResults, WorkflowBuilder<TResult>>
): NextWorkflowBuilder<
TResults,
TName,
(TName extends keyof TResults ? TResults[TName] : never) | TResult | undefined
> {
this.steps.push({
name,
prompt: ({ results }) => {
return subWorkflow({ results }).run();
},
condition,
setResult: true,
});
return this as any;
}

public logStep(
name: string,
prompt: PromptWithOptions<TResults, void>
): WorkflowBuilder<TResults> {
this.steps.push({ name, prompt, setResult: false });
return this;
}

public customStep<TName extends string, TResult>(
step: WorkflowStep<TName, TResults, TResult>
): NextWorkflowBuilder<TResults, TName, PromptAwaitedReturn<TResult>> {
this.steps.push(step);
return this as any;
}

public onCancel(cb: PromptWithOptions<Partial<TResults>, void>): WorkflowBuilder<TResults> {
this.cancelCallback = cb;
return this;
}

public async run(): Promise<TResults> {
for (const step of this.steps) {
if (step.condition && !step.condition({ results: this.results as any })) {
continue;
}
const result = await step.prompt({ results: this.results as any });
if (isCancel(result)) {
this.cancelCallback?.({ results: this.results as any });
continue;
}
if (step.setResult) {
//@ts-ignore
this.results[step.name] = result;
}
}
return this.results;
}
}

export const workflow = () => {
return new WorkflowBuilder();
};

export type Task = {
/**
* Task title
Expand Down