Skip to content
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

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

Open
wants to merge 17 commits 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
5 changes: 5 additions & 0 deletions .changeset/red-walls-greet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clack/prompts': minor
---

add prompt `workflow` builder
3 changes: 2 additions & 1 deletion examples/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"scripts": {
"start": "jiti ./index.ts",
"spinner": "jiti ./spinner.ts",
"spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts"
"spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.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();
})();
68 changes: 67 additions & 1 deletion packages/prompts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ s.stop('Installed via npm');

## Utilities

### Grouping
### Group

Grouping prompts together is a great way to keep your code organized. This accepts a JSON object with a name that can be used to reference the group later. The second argument is an optional but has a `onCancel` callback that will be called if the user cancels one of the prompts in the group.

Expand Down Expand Up @@ -189,3 +189,69 @@ log.message('Hello, World', { symbol: color.cyan('~') });
```

[clack-log-prompts](https://github.com/natemoo-re/clack/blob/main/.github/assets/clack-logs.png)

### 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);
```
152 changes: 138 additions & 14 deletions packages/prompts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -774,36 +774,45 @@ export const spinner = () => {
};
};

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]>;
}>;

// biome-ignore lint/complexity/noBannedTypes: <explanation>
export type PromptWithOptions<TResults, TResult, 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 Down Expand Up @@ -857,3 +866,118 @@ export const tasks = async (tasks: Task[]) => {
s.stop(result || task.title);
}
};

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();
};
1 change: 0 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading