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: support for custom commands #8

Merged
merged 8 commits into from
Jul 30, 2022
Merged
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
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ Currently implemented (though not every option is supported):
- [`test`](https://man7.org/linux/man-pages/man1/test.1.html) - Test command.
- More to come. Will try to get a similar list as https://deno.land/manual/tools/task_runner#built-in-commands

You can also register your own commands with the shell parser (see below).

## Builder APIs

The builder APIs are what the library uses internally and they're useful for scenarios where you want to re-use some setup state. They're immutable so every function call returns a new object (which is the same thing that happens with the objects returned from `$` and `$.request`).
Expand Down Expand Up @@ -259,6 +261,21 @@ const result2 = await otherBuilder
.spawn();
```

You can also register your own custom commands using the `registerCommand` or `registerCommands` methods:

```ts
const commandBuilder = new CommandBuilder()
.registerCommand(
"true",
() => Promise.resolve({ kind: "continue", code: 0 }),
);

const result = await commandBuilder
// now includes the 'true' command
.command("true && echo yay")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to build in true and false by the way :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I knew you were going to say that :)

I have a few more tasks yet to work through, so, I imagine there will be a PR or two coming with more commands. I'll put those on my list :)

.spawn();
```

### `RequestBuilder`

`RequestBuilder` can be used for building up requests similar to `$.request`:
Expand All @@ -278,7 +295,7 @@ const result = await requestBuilder

### Custom `$`

You may wish to create your own `$` function that has a certain setup context (for example, a defined environment variable or cwd). You may do this by using the exported `build$` with `CommandBuilder` and/or `RequestBuilder`, which is what the main default exported `$` function uses internally to build itself:
You may wish to create your own `$` function that has a certain setup context (for example, custom commands, a defined environment variable or cwd). You may do this by using the exported `build$` with `CommandBuilder` and/or `RequestBuilder`, which is what the main default exported `$` function uses internally to build itself:

```ts
import {
Expand Down
115 changes: 97 additions & 18 deletions mod.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import $, { build$, CommandBuilder } from "./mod.ts";
import $, { build$, CommandBuilder, CommandContext, CommandHandler } from "./mod.ts";
import { assertEquals, assertRejects, assertThrows } from "./src/deps.test.ts";
import { Buffer, path } from "./src/deps.ts";

Expand Down Expand Up @@ -168,6 +168,85 @@ Deno.test("should handle boolean list 'and'", async () => {
}
});

Deno.test("should support custom command handlers", async () => {
const builder = new CommandBuilder()
.registerCommand("zardoz-speaks", async (context) => {
if (context.args.length != 1) {
await context.stderr.writeLine("zardoz-speaks: expected 1 argument");
return {
kind: "continue",
code: 1,
};
}
await context.stdout.writeLine(`zardoz speaks to ${context.args[0]}`);
return {
kind: "continue",
code: 0,
};
})
.registerCommands({
"true": () => Promise.resolve({ kind: "continue", code: 0 }),
"false": () => Promise.resolve({ kind: "continue", code: 1 }),
});

{
const result = await builder.command("zardoz-speaks").noThrow();
assertEquals(result.code, 1);
assertEquals(result.stderr, "zardoz-speaks: expected 1 argument\n");
}
{
const result = await builder.command("zardoz-speaks to you").noThrow();
assertEquals(result.code, 1);
assertEquals(result.stderr, "zardoz-speaks: expected 1 argument\n");
}
{
const result = await builder.command("zardoz-speaks you").noThrow();
assertEquals(result.code, 0);
assertEquals(result.stdout, "zardoz speaks to you\n");
}
{
const result = await builder.command("true && echo yup").noThrow();
assertEquals(result.code, 0);
assertEquals(result.stdout, "yup\n");
}
{
const result = await builder.command("false && echo nope").noThrow();
assertEquals(result.code, 1);
assertEquals(result.stdout, "");
}
});

Deno.test("should not allow invalid command names", async () => {
const builder = new CommandBuilder();
const hax: CommandHandler = async (context: CommandContext) => {
context.stdout.writeLine("h4x!1!");
return {
kind: "continue",
code: 0,
};
};

assertThrows(
() => builder.registerCommand("/dev/null", hax),
Error,
"Invalid command name",
);
assertThrows(
() => builder.registerCommand("*", hax),
Error,
"Invalid command name",
);
});

Deno.test("should unregister commands", async () => {
const builder = new CommandBuilder().unregisterCommand("export").noThrow();
await assertRejects(
async () => await builder.command("export somewhere"),
Error,
"Command not found: export",
);
});

Deno.test("sleep command", async () => {
const start = performance.now();
const result = await $`sleep 0.2 && echo 1`.text();
Expand All @@ -177,12 +256,12 @@ Deno.test("sleep command", async () => {
});

Deno.test("test command", async (t) => {
await Deno.writeFile('zero.dat', new Uint8Array());
await Deno.writeFile('non-zero.dat', new Uint8Array([242]));
if (Deno.build.os !== 'windows') {
await Deno.symlink('zero.dat', 'linked.dat');
await Deno.writeFile("zero.dat", new Uint8Array());
await Deno.writeFile("non-zero.dat", new Uint8Array([242]));
if (Deno.build.os !== "windows") {
await Deno.symlink("zero.dat", "linked.dat");
}

await t.step("test -e", async () => {
const result = await $`test -e zero.dat`.noThrow();
assertEquals(result.code, 0);
Expand All @@ -196,11 +275,11 @@ Deno.test("test command", async (t) => {
assertEquals(result.code, 1, "should not be a file");
assertEquals(result.stderr, "");
});
await t.step("test -d", async() => {
await t.step("test -d", async () => {
const result = await $`test -d ${Deno.cwd()}`.noThrow();
assertEquals(result.code, 0, `${Deno.cwd()} should be a directory`);
});
await t.step("test -d on non-directory", async() => {
await t.step("test -d on non-directory", async () => {
const result = await $`test -d zero.dat`.noThrow();
assertEquals(result.code, 1, "should not be a directory");
assertEquals(result.stderr, "");
Expand All @@ -215,7 +294,7 @@ Deno.test("test command", async (t) => {
assertEquals(result.code, 1, "should fail as file is zero-sized");
assertEquals(result.stderr, "");
});
if (Deno.build.os !== 'windows') {
if (Deno.build.os !== "windows") {
await t.step("test -L", async () => {
const result = await $`test -L linked.dat`.noThrow();
assertEquals(result.code, 0, "should be a symlink");
Expand All @@ -224,19 +303,19 @@ Deno.test("test command", async (t) => {
await t.step("test -L on a non-symlink", async () => {
const result = await $`test -L zero.dat`.noThrow();
assertEquals(result.code, 1, "should fail as not a symlink");
assertEquals(result.stderr, "");
assertEquals(result.stderr, "");
});
await t.step("should error on unsupported test type", async () => {
const result = await $`test -z zero.dat`.noThrow();
assertEquals(result.code, 2, "should have exit code 2");
assertEquals(result.stderr, "test: unsupported test type\n");
});
await t.step("should error with not enough arguments", async() => {
await t.step("should error with not enough arguments", async () => {
const result = await $`test`.noThrow();
assertEquals(result.code, 2, "should have exit code 2");
assertEquals(result.stderr, "test: expected 2 arguments\n");
});
await t.step("should error with too many arguments", async() => {
await t.step("should error with too many arguments", async () => {
const result = await $`test -f a b c`.noThrow();
assertEquals(result.code, 2, "should have exit code 2");
assertEquals(result.stderr, "test: expected 2 arguments\n");
Expand All @@ -262,14 +341,14 @@ Deno.test("test command", async (t) => {
assertEquals(result.stdout, "yup\n");
});

if (Deno.build.os !== 'windows') {
await Deno.remove('linked.dat');
if (Deno.build.os !== "windows") {
await Deno.remove("linked.dat");
}
await Deno.remove('zero.dat');
await Deno.remove('non-zero.dat');
await Deno.remove("zero.dat");
await Deno.remove("non-zero.dat");
});

Deno.test("exit command", async() => {
Deno.test("exit command", async () => {
{
const result = await $`exit`.noThrow();
assertEquals(result.code, 1);
Expand Down Expand Up @@ -302,7 +381,7 @@ Deno.test("exit command", async() => {
{
const result = await $`exit 1 1`.noThrow();
assertEquals(result.code, 2);
assertEquals(result.stderr, "exit: too many arguments\n");
assertEquals(result.stderr, "exit: too many arguments\n");
}
});

Expand Down
10 changes: 10 additions & 0 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,17 @@ import { colors, fs, path, which, whichSync } from "./src/deps.ts";
import { RequestBuilder } from "./src/request.ts";

export { CommandBuilder, CommandResult } from "./src/command.ts";
export type { CommandContext, CommandHandler, CommandPipeWriter } from "./src/command_handler.ts";
export { RequestBuilder, RequestResult } from "./src/request.ts";
export type {
CdChange,
ContinueExecuteResult,
EnvChange,
ExecuteResult,
ExitExecuteResult,
SetEnvVarChange,
SetShellVarChange,
} from "./src/result.ts";

/**
* Cross platform shell tools for Deno inspired by [zx](https://github.com/google/zx).
Expand Down
56 changes: 56 additions & 0 deletions src/command.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import { CommandHandler } from "./command_handler.ts";
import { cdCommand } from "./commands/cd.ts";
import { echoCommand } from "./commands/echo.ts";
import { exitCommand } from "./commands/exit.ts";
import { exportCommand } from "./commands/export.ts";
import { sleepCommand } from "./commands/sleep.ts";
import { testCommand } from "./commands/test.ts";
import { delayToMs } from "./common.ts";
import { Delay } from "./common.ts";
import { Buffer, path } from "./deps.ts";
Expand All @@ -19,13 +26,23 @@ interface CommandBuilderState {
stderrKind: ShellPipeWriterKind;
noThrow: boolean;
env: Record<string, string>;
commands: Record<string, CommandHandler>;
cwd: string;
exportEnv: boolean;
timeout: number | undefined;
}

const textDecoder = new TextDecoder();

const builtInCommands = {
cd: cdCommand,
echo: echoCommand,
exit: exitCommand,
export: exportCommand,
sleep: sleepCommand,
test: testCommand,
};

/**
* The underlying builder API for executing commands.
*
Expand Down Expand Up @@ -63,6 +80,7 @@ export class CommandBuilder implements PromiseLike<CommandResult> {
noThrow: state.noThrow,
env: { ...state.env },
cwd: state.cwd,
commands: { ...state.commands },
exportEnv: state.exportEnv,
timeout: state.timeout,
};
Expand All @@ -77,6 +95,7 @@ export class CommandBuilder implements PromiseLike<CommandResult> {
noThrow: false,
env: Deno.env.toObject(),
cwd: Deno.cwd(),
commands: { ...builtInCommands },
exportEnv: false,
timeout: undefined,
};
Expand Down Expand Up @@ -109,6 +128,36 @@ export class CommandBuilder implements PromiseLike<CommandResult> {
return parseAndSpawnCommand(this.#getClonedState());
}

/**
* Register a command.
*/
registerCommand(command: string, handleFn: CommandHandler) {
validateCommandName(command);
return this.#newWithState(state => {
state.commands[command] = handleFn;
});
}

/**
* Register multilple commands.
*/
registerCommands(commands: Record<string, CommandHandler>) {
let command: CommandBuilder = this;
for (const [key, value] of Object.entries(commands)) {
command = command.registerCommand(key, value);
}
return command;
}

/**
* Unregister a command.
*/
unregisterCommand(command: string) {
return this.#newWithState(state => {
delete state.commands[command];
});
}

/** Sets the raw command to execute. */
command(command: string | string[]) {
return this.#newWithState(state => {
Expand Down Expand Up @@ -355,6 +404,7 @@ export async function parseAndSpawnCommand(state: CommandBuilderState) {
stdout,
stderr,
env: state.env,
commands: state.commands,
cwd: state.cwd,
exportEnv: state.exportEnv,
signal: abortController.signal,
Expand Down Expand Up @@ -464,3 +514,9 @@ export function escapeArg(arg: string) {
return `'${arg.replace("'", `'"'"'`)}'`;
}
}

function validateCommandName(command: string) {
if (command.match(/^[a-zA-Z0-9-_]+$/) == null) {
throw new Error("Invalid command name");
}
}
19 changes: 19 additions & 0 deletions src/command_handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ExecuteResult } from "./result.ts";

export type CommandPipeReader = "inherit" | "null" | Deno.Reader;

export interface CommandPipeWriter extends Deno.Writer {
write(p: Uint8Array): Promise<number>;
writeText(text: string): Promise<void>;
writeLine(text: string): Promise<void>;
}

export interface CommandContext {
get args(): string[];
get cwd(): string;
get stdin(): CommandPipeReader;
get stdout(): CommandPipeWriter;
get stderr(): CommandPipeWriter;
}

export type CommandHandler = (context: CommandContext) => Promise<ExecuteResult>;
8 changes: 4 additions & 4 deletions src/commands/cd.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { CommandContext } from "../command_handler.ts";
import { resolvePath } from "../common.ts";
import { ShellPipeWriter } from "../pipes.ts";
import { ExecuteResult, resultFromCode } from "../result.ts";

export async function cdCommand(cwd: string, args: string[], stderr: ShellPipeWriter): Promise<ExecuteResult> {
export async function cdCommand(context: CommandContext): Promise<ExecuteResult> {
try {
const dir = await executeCd(cwd, args);
const dir = await executeCd(context.cwd, context.args);
return {
code: 0,
kind: "continue",
Expand All @@ -14,7 +14,7 @@ export async function cdCommand(cwd: string, args: string[], stderr: ShellPipeWr
}],
};
} catch (err) {
await stderr.writeLine(`cd: ${err?.message ?? err}`);
await context.stderr.writeLine(`cd: ${err?.message ?? err}`);
return resultFromCode(1);
}
}
Expand Down
Loading