Skip to content

Commit

Permalink
feat: improve type inference on subcommands
Browse files Browse the repository at this point in the history
  • Loading branch information
adbayb committed Mar 23, 2022
1 parent 4e9bb1d commit 457804a
Show file tree
Hide file tree
Showing 16 changed files with 348 additions and 297 deletions.
29 changes: 23 additions & 6 deletions example/withCommand.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,43 @@
import { termost } from "../src";

type ProgramContext = {
sharedFlag: boolean;
globalFlag: boolean;
};

const program = termost<ProgramContext>(
"Example to showcase the `command` API"
);

program.option({
key: "sharedFlag",
name: "flag",
key: "globalFlag",
name: "global",
description: "Shared flag between commands",
defaultValue: false,
});

type BuildCommandContext = {
localFlag: string;
};

program
.command({
.command<BuildCommandContext>({
name: "build",
description: "Transpile and bundle in production mode",
})
.option({
key: "localFlag",
name: "local",
description: "Local command flag",
defaultValue: "local-value",
})
.message({
handler(context, helpers) {
helpers.print(`👋 Hello, I'm the ${context.args.command} command`);
helpers.print(`👉 Shared flag = ${context.values.sharedFlag}`);

const { localFlag, globalFlag } = context.values;

helpers.print(`👉 Shared global flag = ${globalFlag}`);
helpers.print(`👉 Local command flag = ${localFlag}`);
},
});

Expand All @@ -35,6 +49,9 @@ program
.message({
handler(context, helpers) {
helpers.print(`👋 Hello, I'm the ${context.args.command} command`);
helpers.print(`👉 Shared flag = ${context.values.sharedFlag}`);

const { globalFlag } = context.values;

helpers.print(`👉 Shared global flag = ${globalFlag}`);
},
});
1 change: 0 additions & 1 deletion example/withQuestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ program
key: "question1",
label: "What is your single choice?",
options: ["singleOption1", "singleOption2"],
defaultValue: "singleOption2",
})
.question({
type: "multiselect",
Expand Down
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@
],
"scripts": {
"prepublishOnly": "yarn verify && yarn build",
"build": "rm -rf dist && tsc",
"clean": "rm -rf dist",
"build": "yarn clean && tsc",
"watch": "yarn clean && tsc -w",
"example": "node -r esbuild-register example/index.ts",
"verify": "yarn lint & yarn types && yarn test",
"fix": "yarn lint --fix",
Expand All @@ -52,14 +54,14 @@
"@semantic-release/git": "10.0.1",
"@types/jest": "27.4.1",
"@types/prompts": "2.0.14",
"esbuild": "0.14.25",
"esbuild": "0.14.27",
"esbuild-jest": "0.5.0",
"esbuild-register": "3.3.2",
"eslint": "8.11.0",
"husky": "4.3.8",
"jest": "27.5.1",
"lint-staged": "12.3.5",
"prettier": "2.5.1",
"lint-staged": "12.3.7",
"prettier": "2.6.0",
"semantic-release": "19.0.2",
"typescript": "4.6.2"
},
Expand Down
2 changes: 1 addition & 1 deletion src/core/process/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export const exec = async (command: string, options: ExecOptions = {}) => {
return new Promise<string>((resolve, reject) => {
let stdout = "";
let stderr = "";
const [bin, ...args] = command.split(" ") as [string, ...string[]];
const [bin, ...args] = command.split(" ") as [string, ...Array<string>];

const childProcess = spawn(bin, args, {
cwd: options.cwd,
Expand Down
6 changes: 4 additions & 2 deletions src/features/command/command.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { DEFAULT_COMMAND_NAME } from "../../constants";
import { format } from "../message/helpers";
import { CommandName, Context } from "../types";
import { CommandName, Context, ObjectLikeConstraint } from "../types";
import { FluentInterface } from "./fluentInterface";

export class Command<Values> extends FluentInterface<Values> {
export class Command<
Values extends ObjectLikeConstraint
> extends FluentInterface<Values> {
/**
* The command instance name used as unique identifier
*/
Expand Down
11 changes: 8 additions & 3 deletions src/features/command/fluentInterface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ import {
} from "../option";
import { QuestionParameters, createQuestion } from "../question";
import { TaskParameters, createTask } from "../task";
import { Context, CreateInstruction, InstructionParameters } from "../types";
import {
Context,
CreateInstruction,
InstructionParameters,
ObjectLikeConstraint,
} from "../types";

export class FluentInterface<Values> {
export class FluentInterface<Values extends ObjectLikeConstraint> {
// @note: soft private through protected access modifier (type checking only but still accessible runtime side)
// since JavaScript runtime doesn't handle yet access to private field from inherited classes:
protected context: Context<Values>;
Expand Down Expand Up @@ -66,7 +71,7 @@ export class FluentInterface<Values> {

if (instructionValue && instructionValue.key) {
this.context.values[instructionValue.key as keyof Values] =
instructionValue.value;
instructionValue.value as Values[keyof Values];
}
});

Expand Down
36 changes: 34 additions & 2 deletions src/features/message/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,35 @@
export { createMessage } from "./message";
import {
Context,
CreateInstruction,
InstructionParameters,
ObjectLikeConstraint,
} from "../types";
import { format, print } from "./helpers";

export type { MessageParameters } from "./message";
export const createMessage: CreateInstruction<
MessageParameters<ObjectLikeConstraint>
> = (parameters) => {
const { handler } = parameters;

return async function execute(context) {
handler(context, HELPERS);

return null;
};
};

const HELPERS = {
format,
print,
};

export type MessageParameters<Values extends ObjectLikeConstraint> =
InstructionParameters<
Values,
{
handler: (
context: Context<Values>,
helpers: typeof HELPERS
) => void;
}
>;
31 changes: 0 additions & 31 deletions src/features/message/message.ts

This file was deleted.

13 changes: 8 additions & 5 deletions src/features/option/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {
Context,
CreateInstruction,
DefaultValues,
InstructionKey,
InstructionParameters,
ObjectLikeConstraint,
} from "../types";

export const createOption: CreateInstruction<InternalOptionParameters> = (
Expand Down Expand Up @@ -48,13 +48,16 @@ export const createOption: CreateInstruction<InternalOptionParameters> = (
};

export type InternalOptionParameters = OptionParameters<
DefaultValues,
keyof DefaultValues
ObjectLikeConstraint,
keyof ObjectLikeConstraint
> & {
context: Context<DefaultValues>;
context: Context<ObjectLikeConstraint>;
};

export type OptionParameters<Values, Key> = InstructionParameters<
export type OptionParameters<
Values extends ObjectLikeConstraint,
Key
> = InstructionParameters<
Values,
Key extends keyof Values
? InstructionKey<Key> &
Expand Down
12 changes: 7 additions & 5 deletions src/features/question/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import prompts, { PromptObject, PromptType } from "prompts";
import {
CreateInstruction,
DefaultValues,
InstructionKey,
InstructionParameters,
Label,
ObjectLikeConstraint,
} from "../types";

export const createQuestion: CreateInstruction<
QuestionParameters<DefaultValues, keyof DefaultValues>
QuestionParameters<ObjectLikeConstraint, keyof ObjectLikeConstraint>
> = (parameters) => {
const { key, defaultValue, label, type } = parameters;
const mapTypeToPromptType = (): PromptType => {
Expand Down Expand Up @@ -38,12 +38,14 @@ export const createQuestion: CreateInstruction<

if (parameters.type === "select" || parameters.type === "multiselect") {
const isMultiSelect = parameters.type === "multiselect";
const options = parameters.options as string[];
const options = parameters.options as Array<string>;
const choices = options.map((option) => ({
title: option,
value: option,
...(isMultiSelect && {
selected: defaultValue.includes(option),
selected: ((defaultValue || []) as Array<string>).includes(
option
),
}),
}));

Expand All @@ -66,7 +68,7 @@ export const createQuestion: CreateInstruction<
};

export type QuestionParameters<
Values,
Values extends ObjectLikeConstraint,
Key extends keyof Values
> = InstructionParameters<
Values,
Expand Down
66 changes: 64 additions & 2 deletions src/features/task/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,65 @@
export { createTask } from "./task";
import { Listr } from "listr2";
import {
Context,
CreateInstruction,
InstructionKey,
InstructionParameters,
Label,
ObjectLikeConstraint,
} from "../types";
import { exec } from "../../core/process";

export type { TaskParameters } from "./task";
export const createTask: CreateInstruction<
TaskParameters<ObjectLikeConstraint, keyof ObjectLikeConstraint>
> = (parameters) => {
const { key, label, handler } = parameters;
const receiver = new Listr([], {
rendererOptions: {
collapseErrors: false,
formatOutput: "wrap",
showErrorMessage: true,
showTimer: true,
},
});

return async function execute(context) {
let value: unknown;

receiver.add({
title: typeof label === "function" ? label(context) : label,
task: async () => (value = await handler(context, HELPERS)),
});

await receiver.run();

return { key, value };
};
};

const HELPERS = {
exec,
};

export type TaskParameters<
Values extends ObjectLikeConstraint,
Key
> = InstructionParameters<Values, Parameters<Values, Key>>;

type Parameters<
Values extends ObjectLikeConstraint,
Key
> = Key extends keyof Values
? Partial<InstructionKey<Key>> & {
label: Label<Values>;
handler: (
context: Context<Values>,
helpers: typeof HELPERS
) => Promise<Values[Key]>;
}
: {
label: Label<Values>;
handler: (
context: Context<Values>,
helpers: typeof HELPERS
) => void;
};
Loading

0 comments on commit 457804a

Please sign in to comment.