Skip to content

Commit

Permalink
feat: cross platform shebang support (#62)
Browse files Browse the repository at this point in the history
  • Loading branch information
dsherret authored Dec 31, 2022
1 parent ee1b8b9 commit 4886569
Show file tree
Hide file tree
Showing 8 changed files with 317 additions and 28 deletions.
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ const result = await $`echo $TEST`.env("TEST", "123").text();
console.log(result); // 123
```

### Custom Cross Platform Shell Commands
### Custom cross platform shell commands

Currently implemented (though not every option is supported):

Expand All @@ -579,6 +579,21 @@ Currently implemented (though not every option is supported):

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

### Cross platform shebang support

Users on unix-based platforms often write a script like so:

```ts
#!/usr/bin/env -S deno run
console.log("Hello there!");
```

...which can be executed on the command line by running `./file.ts`. This doesn't work on the command line in Windows, but it does on all platforms in dax:

```ts
await $`./file.ts`;
```

## 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
77 changes: 74 additions & 3 deletions mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,77 @@ Deno.test("command .lines()", async () => {
assertEquals(result, ["1", "2"]);
});

Deno.test("shebang support", async (t) => {
await withTempDir(async (dir) => {
const steps: Promise<boolean>[] = [];
const step = (name: string, fn: () => Promise<void>) => {
steps.push(t.step({
name,
fn,
sanitizeExit: false,
sanitizeOps: false,
sanitizeResources: false,
}));
};

step("with -S", async () => {
await Deno.writeTextFile(
$.path.join(dir, "file.ts"),
[
"#!/usr/bin/env -S deno run",
"console.log(5);",
].join("\n"),
);
const output = await $`./file.ts`
.cwd(dir)
.text();
assertEquals(output, "5");
});

step("without -S and invalid", async () => {
await Deno.writeTextFile(
$.path.join(dir, "file2.ts"),
[
"#!/usr/bin/env deno run",
"console.log(5);",
].join("\n"),
);
await assertRejects(
async () => {
await $`./file2.ts`
.cwd(dir)
.text();
},
Error,
"Command not found: deno run",
);
});

step("without -S, but valid", async () => {
await Deno.writeTextFile(
$.path.join(dir, "echo_stdin.ts"),
[
"#!/usr/bin/env -S deno run --unstable --allow-run",
"await new Deno.Command('deno', { args: ['run', ...Deno.args] }).spawn();",
].join("\n"),
);
await Deno.writeTextFile(
$.path.join(dir, "file3.ts"),
[
"#!/usr/bin/env ./echo_stdin.ts",
"console.log('Hello')",
].join("\n"),
);
const output = await $`./file3.ts`
.cwd(dir)
.text();
assertEquals(output, "Hello");
});

await Promise.all(steps);
});
});

Deno.test("basic logging test to ensure no errors", async () => {
assertEquals($.logDepth, 0);
$.logGroup();
Expand Down Expand Up @@ -720,8 +791,8 @@ Deno.test("test remove", async () => {

await $`rm ${emptyDir}`;
await $`rm ${someFile}`;
assertEquals($.fs.existsSync(dir + "/hello"), false);
assertEquals($.fs.existsSync(dir + "/a.txt"), false);
assertEquals($.existsSync(dir + "/hello"), false);
assertEquals($.existsSync(dir + "/a.txt"), false);

const nonEmptyDir = dir + "/a";
Deno.mkdirSync(nonEmptyDir + "/b", { recursive: true });
Expand All @@ -734,7 +805,7 @@ Deno.test("test remove", async () => {
assertEquals(error.substring(0, expectedText.length), expectedText);

await $`rm -r ${nonEmptyDir}`;
assertEquals($.fs.existsSync(nonEmptyDir), false);
assertEquals($.existsSync(nonEmptyDir), false);
});
});

Expand Down
4 changes: 2 additions & 2 deletions src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
ShellPipeWriter,
ShellPipeWriterKind,
} from "./pipes.ts";
import { parseArgs, spawn } from "./shell.ts";
import { parseCommand, spawn } from "./shell.ts";
import { cpCommand, mvCommand } from "./commands/cp_mv.ts";
import { isShowingProgressBars } from "./console/progress/interval.ts";

Expand Down Expand Up @@ -419,7 +419,7 @@ export async function parseAndSpawnCommand(state: CommandBuilderState) {
}

try {
const list = await parseArgs(state.command);
const list = await parseCommand(state.command);
const code = await spawn(list, {
stdin: state.stdin,
stdout,
Expand Down
64 changes: 62 additions & 2 deletions src/common.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { delayToIterator, delayToMs, formatMillis, getFileNameFromUrl, resolvePath, TreeBox } from "./common.ts";
import {
delayToIterator,
delayToMs,
formatMillis,
getExecutableShebang,
getFileNameFromUrl,
resolvePath,
TreeBox,
} from "./common.ts";
import { assertEquals } from "./deps.test.ts";
import { path } from "./deps.ts";
import { Buffer, path } from "./deps.ts";

Deno.test("should get delay value", () => {
assertEquals(delayToMs(10), 10);
Expand Down Expand Up @@ -84,3 +92,55 @@ Deno.test("gets file name from url", () => {
assertEquals(getFileNameFromUrl("https://deno.land/file/other"), "other");
assertEquals(getFileNameFromUrl("https://deno.land/file/other/"), undefined);
});

Deno.test("gets the executable shebang when it exists", async () => {
function get(input: string) {
const data = new TextEncoder().encode(input);
const buffer = new Buffer(data);
return getExecutableShebang(buffer);
}

assertEquals(
await get("#!/usr/bin/env -S deno run --allow-env=PWD --allow-read=."),
{
stringSplit: true,
command: "deno run --allow-env=PWD --allow-read=.",
},
);
assertEquals(
await get("#!/usr/bin/env -S deno run --allow-env=PWD --allow-read=.\n"),
{
stringSplit: true,
command: "deno run --allow-env=PWD --allow-read=.",
},
);
assertEquals(
await get("#!/usr/bin/env -S deno run --allow-env=PWD --allow-read=.\ntesting\ntesting"),
{
stringSplit: true,
command: "deno run --allow-env=PWD --allow-read=.",
},
);
assertEquals(
await get("#!/usr/bin/env -S deno run --allow-env=PWD --allow-read=.\r\ntesting\ntesting"),
{
stringSplit: true,
command: "deno run --allow-env=PWD --allow-read=.",
},
);
assertEquals(
await get("#!/usr/bin/env deno run --allow-env=PWD --allow-read=.\r\ntesting\ntesting"),
{
stringSplit: false,
command: "deno run --allow-env=PWD --allow-read=.",
},
);
assertEquals(
await get("#!/bin/sh\r\ntesting\ntesting"),
undefined,
);
assertEquals(
await get("testing"),
undefined,
);
});
62 changes: 61 additions & 1 deletion src/common.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { logger } from "./console/mod.ts";
import { path } from "./deps.ts";
import { BufReader, path } from "./deps.ts";

/**
* Delay used for certain actions.
Expand Down Expand Up @@ -137,3 +137,63 @@ export function getFileNameFromUrl(url: string | URL) {
const fileName = parsedUrl.pathname.split("/").at(-1);
return fileName?.length === 0 ? undefined : fileName;
}

/**
* Gets an executable shebang from the provided file path.
* @returns
* - An object outlining information about the shebang.
* - `undefined` if the file exists, but doesn't have a shebang.
* - `false` if the file does NOT exist.
*/
export async function getExecutableShebangFromPath(path: string) {
try {
const file = await Deno.open(path, { read: true });
try {
return await getExecutableShebang(file);
} finally {
try {
file.close();
} catch {
// ignore
}
}
} catch (err) {
if (err instanceof Deno.errors.NotFound) {
return false;
}
throw err;
}
}

export interface ShebangInfo {
stringSplit: boolean;
command: string;
}

const decoder = new TextDecoder();
export async function getExecutableShebang(reader: Deno.Reader): Promise<ShebangInfo | undefined> {
const text = "#!/usr/bin/env ";
const buffer = new Uint8Array(text.length);
const bytesReadCount = await reader.read(buffer);
if (bytesReadCount !== text.length || decoder.decode(buffer) !== text) {
return undefined;
}
const bufReader = new BufReader(reader);
const line = await bufReader.readLine();
if (line == null) {
return undefined;
}
const result = decoder.decode(line.line).trim();
const dashS = "-S ";
if (result.startsWith(dashS)) {
return {
stringSplit: true,
command: result.slice(dashS.length),
};
} else {
return {
stringSplit: false,
command: result,
};
}
}
1 change: 1 addition & 0 deletions src/deps.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * as colors from "https://deno.land/std@0.170.0/fmt/colors.ts";
export * as fs from "https://deno.land/std@0.170.0/fs/mod.ts";
export { Buffer } from "https://deno.land/std@0.170.0/io/buffer.ts";
export { BufReader } from "https://deno.land/std@0.170.0/io/buf_reader.ts";
export * as path from "https://deno.land/std@0.170.0/path/mod.ts";
export { readAll } from "https://deno.land/std@0.170.0/streams/read_all.ts";
export { writeAllSync } from "https://deno.land/std@0.170.0/streams/write_all.ts";
Expand Down
20 changes: 14 additions & 6 deletions src/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,13 @@ Deno.test("$.request", (t) => {
step("404", async () => {
const request404 = new RequestBuilder()
.url(new URL("/code/404", serverUrl));
assertRejects(async () => {
await request404.text();
});
assertRejects(
async () => {
await request404.text();
},
Error,
"Not Found",
);

assertEquals(await request404.noThrow(404).blob(), undefined);
assertEquals(await request404.noThrow(404).arrayBuffer(), undefined);
Expand All @@ -156,9 +160,13 @@ Deno.test("$.request", (t) => {
step("500", async () => {
const request500 = new RequestBuilder()
.url(new URL("/code/500", serverUrl));
assertRejects(async () => {
await request500.text();
});
assertRejects(
async () => {
await request500.text();
},
Error,
"500",
);

assertEquals(await request500.noThrow(500).text(), "500");
});
Expand Down
Loading

0 comments on commit 4886569

Please sign in to comment.