diff --git a/biome.json b/biome.json index 5ef176b..441723b 100644 --- a/biome.json +++ b/biome.json @@ -26,6 +26,7 @@ "useNamingConvention": "off" }, "complexity": { + "noVoid": "off", "useLiteralKeys": "off" }, "correctness": { diff --git a/packages/platform-deno/deno.json b/packages/platform-deno/deno.json index 30e53f1..4262015 100644 --- a/packages/platform-deno/deno.json +++ b/packages/platform-deno/deno.json @@ -3,6 +3,7 @@ "version": "0.1.0", "exports": { ".": "./src/mod.ts", + "./DenoCommandExecutor": "./src/DenoCommandExecutor.ts", "./DenoContext": "./src/DenoContext.ts", "./DenoFileSystem": "./src/DenoFileSystem.ts", "./DenoPath": "./src/DenoPath.ts", diff --git a/packages/platform-deno/src/DenoCommandExecutor.ts b/packages/platform-deno/src/DenoCommandExecutor.ts new file mode 100644 index 0000000..342b3dc --- /dev/null +++ b/packages/platform-deno/src/DenoCommandExecutor.ts @@ -0,0 +1,270 @@ +/** + * This modules exposes primitives for running subprocesses to Effect-based applications that use Deno. + * @module + * + * @since 0.1.2 + */ + +import { Command, CommandExecutor, FileSystem } from "@effect/platform"; +import type { PlatformError } from "@effect/platform/Error"; +import { + Deferred, + Effect, + Inspectable, + Layer, + Option, + type Scope, + Sink, + Stream, + identity, + pipe, +} from "effect"; +import { constUndefined } from "effect/Function"; +import { handleErrnoException } from "./internal/error.ts"; +import { fromWritable } from "./internal/sink.ts"; + +const inputToStdioOption = ( + stdin: Command.Command.Input, +): "piped" | "inherit" => + typeof stdin === "string" ? (stdin === "pipe" ? "piped" : stdin) : "piped"; + +const outputToStdioOption = ( + output: Command.Command.Output, +): "piped" | "inherit" => + typeof output === "string" ? (output === "pipe" ? "piped" : output) : "piped"; + +const toError = (err: unknown): Error => + err instanceof globalThis.Error ? err : new globalThis.Error(String(err)); + +const toPlatformError = ( + method: string, + error: Error, + command: Command.Command, +): PlatformError => { + const flattened = Command.flatten(command).reduce((acc, curr) => { + const command = `${curr.command} ${curr.args.join(" ")}`; + return acc.length === 0 ? command : `${acc} | ${command}`; + }, ""); + return handleErrnoException("Command", method)(error, [flattened]); +}; + +type ExitCode = readonly [code: number | null, signal: Deno.Signal | null]; +type ExitCodeDeferred = Deferred.Deferred; + +const ProcessProto = { + [CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId, + ...Inspectable.BaseProto, + toJSON(this: CommandExecutor.Process): object { + return { + _id: Symbol.keyFor(CommandExecutor.ProcessTypeId), + pid: this.pid, + }; + }, +}; + +const runCommand = + (fileSystem: FileSystem.FileSystem) => + ( + command: Command.Command, + ): Effect.Effect => { + switch (command._tag) { + case "StandardCommand": { + const spawn = Effect.flatMap(Deferred.make(), (exitCode) => + Effect.tryPromise< + readonly [Deno.ChildProcess, ExitCodeDeferred], + PlatformError + >({ + // deno-lint-ignore require-await -- We need the abort signal. + try: async ( + signal, + ): Promise => { + const comm = new Deno.Command(command.command, { + // TODO: PR Deno to make args as immutable. + // @ts-expect-error: args is mutable, command.args is immutable. + args: command.args, + stdin: inputToStdioOption(command.stdin), + stdout: outputToStdioOption(command.stdout), + stderr: outputToStdioOption(command.stderr), + cwd: Option.getOrElse(command.cwd, constUndefined), + env: { + ...Deno.env.toObject(), + ...Object.fromEntries(command.env), + }, + signal, + }); + const handle = comm.spawn(); + + void handle.status.then((status) => { + Deferred.unsafeDone( + exitCode, + Effect.succeed([status.code, status.signal]), + ); + }); + + return [handle, exitCode]; + }, + catch: (err): PlatformError => + toPlatformError("spawn", err as Error, command), + }), + ); + return pipe( + // Validate that the directory is accessible + Option.match(command.cwd, { + onNone: (): Effect.Effect => Effect.void, + onSome: (dir): Effect.Effect => + fileSystem.access(dir), + }), + Effect.zipRight( + Effect.acquireRelease(spawn, ([handle, exitCode]) => + Effect.flatMap(Deferred.isDone(exitCode), (done) => + done + ? Effect.void + : Effect.suspend(() => { + handle.kill("SIGTERM"); + return Deferred.await(exitCode); + }), + ), + ), + ), + Effect.map(([handle, exitCodeDeferred]): CommandExecutor.Process => { + let stdin: Sink.Sink = + Sink.drain; + + stdin = fromWritable( + () => handle.stdin, + (err: unknown) => + toPlatformError("toWritable", toError(err), command), + ); + + const exitCode: CommandExecutor.Process["exitCode"] = + Effect.flatMap( + Deferred.await(exitCodeDeferred), + ([code, signal]) => { + if (code !== null) { + return Effect.succeed(CommandExecutor.ExitCode(code)); + } + // If code is `null`, then `signal` must be defined. See the NodeJS + // documentation for the `"exit"` event on a `child_process`. + // https://nodejs.org/api/child_process.html#child_process_event_exit + return Effect.fail( + toPlatformError( + "exitCode", + new globalThis.Error( + `Process interrupted due to receipt of signal: ${signal}`, + ), + command, + ), + ); + }, + ); + + const isRunning = Effect.negate(Deferred.isDone(exitCodeDeferred)); + + const kill: CommandExecutor.Process["kill"] = ( + signal = "SIGTERM", + ) => + Effect.suspend(() => { + handle.kill( + // Deno's Signal type is slightly different. + // They support `SIGEMT`, but don't support `SIGIOT` or `SIGLOST`. + // Presumably, there's no runtime validation, so it should be fine. + signal as Deno.Signal, + ); + return Effect.asVoid(Deferred.await(exitCodeDeferred)); + }); + + const pid = CommandExecutor.ProcessId(handle.pid); + const stderr = Stream.fromReadableStream( + () => handle.stderr, + (err: unknown) => + toPlatformError( + "fromReadableStream(stderr)", + toError(err), + command, + ), + ); + let stdout: Stream.Stream = + Stream.fromReadableStream( + () => handle.stdout, + (err: unknown) => + toPlatformError( + "fromReadableStream(stdout)", + toError(err), + command, + ), + ); + // TODO: add Sink.isSink + if (typeof command.stdout !== "string") { + stdout = Stream.transduce(stdout, command.stdout); + } + return Object.assign(Object.create(ProcessProto), { + pid, + exitCode, + isRunning, + kill, + stdin, + stderr, + stdout, + }); + }), + typeof command.stdin === "string" + ? identity + : Effect.tap((process) => + Effect.forkDaemon( + Stream.run( + command.stdin as Stream.Stream, + process.stdin, + ), + ), + ), + ); + } + case "PipedCommand": { + const flattened = Command.flatten(command); + if (flattened.length === 1) { + return pipe(flattened[0], runCommand(fileSystem)); + } + const head = flattened[0]; + const tail = flattened.slice(1); + const initial = tail.slice(0, tail.length - 1); + // TODO: PR Effect to fix this type. + // biome-ignore lint/style/noNonNullAssertion: A pipe always has a `right` element, but types don't use a non-empty tuple. + const last = tail.at(-1)!; + const stream = initial.reduce( + (stdin, command) => + pipe( + Command.stdin(command, stdin), + runCommand(fileSystem), + Effect.map((process) => process.stdout), + Stream.unwrapScoped, + ), + pipe( + runCommand(fileSystem)(head), + Effect.map((process) => process.stdout), + Stream.unwrapScoped, + ), + ); + return pipe(Command.stdin(last, stream), runCommand(fileSystem)); + } + } + }; + +/** + * A {@linkplain Layer.Layer | layer} that provides support for running subprocesses to your app. + * + * @since 1.0.0 + * @category layer + */ +export const layer: Layer.Layer< + CommandExecutor.CommandExecutor, + never, + FileSystem.FileSystem +> = Layer.effect( + CommandExecutor.CommandExecutor, + pipe( + FileSystem.FileSystem, + Effect.map((fileSystem) => + CommandExecutor.makeExecutor(runCommand(fileSystem)), + ), + ), +); diff --git a/packages/platform-deno/src/DenoContext.ts b/packages/platform-deno/src/DenoContext.ts index 22d8664..8b0dfb8 100644 --- a/packages/platform-deno/src/DenoContext.ts +++ b/packages/platform-deno/src/DenoContext.ts @@ -12,9 +12,9 @@ import type { Terminal, Worker, } from "@effect/platform"; -import * as NodeCommandExecutor from "@effect/platform-node-shared/NodeCommandExecutor"; import * as NodeTerminal from "@effect/platform-node-shared/NodeTerminal"; import { Layer } from "effect"; +import * as DenoCommandExecutor from "./DenoCommandExecutor.ts"; import * as DenoFileSystem from "./DenoFileSystem.ts"; import * as DenoPath from "./DenoPath.ts"; import * as DenoWorker from "./DenoWorker.ts"; @@ -40,7 +40,7 @@ export type DenoContext = */ export const layer: Layer.Layer = Layer.mergeAll( DenoPath.layer, - NodeCommandExecutor.layer, + DenoCommandExecutor.layer, NodeTerminal.layer, DenoWorker.layerManager, ).pipe(Layer.provideMerge(DenoFileSystem.layer)); diff --git a/packages/platform-deno/src/internal/sink.ts b/packages/platform-deno/src/internal/sink.ts new file mode 100644 index 0000000..d9ffe79 --- /dev/null +++ b/packages/platform-deno/src/internal/sink.ts @@ -0,0 +1,56 @@ +import * as Channel from "effect/Channel"; +import type * as Chunk from "effect/Chunk"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import type { LazyArg } from "effect/Function"; +import * as Sink from "effect/Sink"; +import { writeInput } from "./stream.ts"; + +/** @internal */ +export const fromWritable = ( + evaluate: LazyArg, + onError: (error: unknown) => E, +): Sink.Sink => + Sink.fromChannel(fromWritableChannel(evaluate, onError)); + +/** @internal */ +export const fromWritableChannel = ( + writable: LazyArg, + onError: (error: unknown) => OE, +): Channel.Channel< + Chunk.Chunk, + Chunk.Chunk, + IE | OE, + IE, + void, + unknown +> => + Channel.flatMap( + Effect.zip( + Effect.sync(() => writable()), + Deferred.make(), + ), + ([writable, deferred]) => + Channel.embedInput( + writableOutput(writable, deferred, onError), + writeInput( + writable, + (cause) => Deferred.failCause(deferred, cause), + Deferred.complete(deferred, Effect.void), + ), + ), + ); + +const writableOutput = ( + writable: WritableStream, + deferred: Deferred.Deferred, + onError: (error: unknown) => E, +): Effect.Effect => + Effect.suspend(() => { + function handleError(err: unknown): void { + Deferred.unsafeDone(deferred, Effect.fail(onError(err))); + } + + void writable.getWriter().closed.catch(handleError); + return Deferred.await(deferred); + }); diff --git a/packages/platform-deno/src/internal/stream.ts b/packages/platform-deno/src/internal/stream.ts new file mode 100644 index 0000000..d475f5c --- /dev/null +++ b/packages/platform-deno/src/internal/stream.ts @@ -0,0 +1,41 @@ +import { type Cause, type Chunk, Effect } from "effect"; +import type * as AsyncInput from "effect/SingleProducerAsyncInput"; + +/** + * @category model + * @since 1.0.0 + */ +export interface FromWritableOptions { + readonly endOnDone?: boolean; +} + +/** @internal */ +export const writeEffect = + ( + writable: WritableStream, + ): ((chunk: Chunk.Chunk) => Effect.Effect) => + (chunk: Chunk.Chunk): Effect.Effect => + chunk.length === 0 + ? Effect.void + : Effect.promise(async () => { + for (const item of chunk) { + await writable.getWriter().write(item); + } + }); + +/** @internal */ +export const writeInput = ( + writable: WritableStream, + onFailure: (cause: Cause.Cause) => Effect.Effect, + onDone = Effect.void, +): AsyncInput.AsyncInputProducer, unknown> => { + const write = writeEffect(writable); + return { + awaitRead: (): Effect.Effect => Effect.void, + emit: write, + error: (cause): Effect.Effect => + Effect.zipRight(Effect.promise(writable.close), onFailure(cause)), + done: (_): Effect.Effect => + Effect.zipRight(Effect.promise(writable.close), onDone), + }; +}; diff --git a/packages/platform-deno/tests/DenoCommandExecutor.test.ts b/packages/platform-deno/tests/DenoCommandExecutor.test.ts new file mode 100644 index 0000000..6321093 --- /dev/null +++ b/packages/platform-deno/tests/DenoCommandExecutor.test.ts @@ -0,0 +1,443 @@ +import { Command, FileSystem, Path } from "@effect/platform"; +import { SystemError } from "@effect/platform/Error"; +import { it } from "@effect/vitest"; +import { + // biome-ignore lint/suspicious/noShadowRestrictedNames: Oh well. + Array, + Chunk, + Effect, + Exit, + Fiber, + Layer, + Option, + Order, + Stream, + TestClock, + pipe, +} from "effect"; +import * as DenoCommandExecutor from "../src/DenoCommandExecutor.ts"; +import * as DenoFileSystem from "../src/DenoFileSystem.ts"; +import * as DenoPath from "../src/DenoPath.ts"; + +const TEST_BASH_SCRIPTS_PATH = [ + // biome-ignore lint/style/noNonNullAssertion: This is a local module. + import.meta.dirname!, + "fixtures", + "bash", +]; + +const TestLive = DenoCommandExecutor.layer.pipe( + Layer.provideMerge(DenoFileSystem.layer), + Layer.merge(DenoPath.layer), +); + +it.layer(TestLive)("Command", (it) => { + it.effect("should convert stdout to a string", ({ expect }) => + Effect.gen(function* () { + const command = Command.make("echo", "-n", "test"); + const result = yield* Command.string(command); + expect(result).toEqual("test"); + }), + ); + + it.effect("should convert stdout to a list of lines", ({ expect }) => + Effect.gen(function* () { + const command = Command.make("echo", "-n", "1\n2\n3"); + const result = yield* Command.lines(command); + expect(result).toEqual(["1", "2", "3"]); + }), + ); + + it.effect("should stream lines of output", ({ expect }) => + Effect.gen(function* () { + const command = Command.make("echo", "-n", "1\n2\n3"); + const result = yield* Stream.runCollect(Command.streamLines(command)); + expect(Chunk.toReadonlyArray(result)).toEqual(["1", "2", "3"]); + }), + ); + + it.effect("should work with a Stream directly", ({ expect }) => + Effect.gen(function* () { + const decoder = new TextDecoder("utf-8"); + const command = Command.make("echo", "-n", "1\n2\n3"); + const result = yield* pipe( + Command.stream(command), + Stream.mapChunks(Chunk.map((bytes) => decoder.decode(bytes))), + Stream.splitLines, + Stream.runCollect, + ); + expect(Chunk.toReadonlyArray(result)).toEqual(["1", "2", "3"]); + }), + ); + + it.effect( + "should fail when trying to run a command that does not exist", + ({ expect }) => + Effect.gen(function* () { + const command = Command.make("some-invalid-command", "test"); + const result = yield* Effect.exit(Command.string(command)); + expect(result).toStrictEqual( + Exit.fail( + SystemError({ + reason: "NotFound", + module: "Command", + method: "spawn", + pathOrDescriptor: "some-invalid-command test", + syscall: "spawn some-invalid-command", + message: "spawn some-invalid-command ENOENT", + }), + ), + ); + }), + { fails: true }, // Deno has worse errors. + ); + + it.effect("should pass environment variables", ({ expect }) => + Effect.gen(function* () { + const command = pipe( + Command.make("bash", "-c", 'echo -n "var = $VAR"'), + Command.env({ VAR: "myValue" }), + ); + const result = yield* Command.string(command); + expect(result).toBe("var = myValue"); + }), + ); + + it.effect( + "should accept streaming stdin", + ({ expect }) => + Effect.gen(function* () { + const stdin = Stream.make(new TextEncoder().encode("a b c")); + const command = pipe(Command.make("cat"), Command.stdin(stdin)); + const result = yield* Command.string(command); + expect(result).toEqual("a b c"); + }), + { fails: true }, // Times out. + ); + + it.effect( + "should accept string stdin", + ({ expect }) => + Effect.gen(function* () { + const stdin = "piped in"; + const command = pipe(Command.make("cat"), Command.feed(stdin)); + const result = yield* Command.string(command); + expect(result).toEqual("piped in"); + }), + { fails: true }, // Times out. + ); + + it.effect("should set the working directory", ({ expect }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const command = pipe( + Command.make("ls"), + Command.workingDirectory( + // biome-ignore lint/style/noNonNullAssertion: This is a local module. + path.join(import.meta.dirname!, "..", "src"), + ), + ); + const result = yield* Command.lines(command); + expect(result).toContain("DenoCommandExecutor.ts"); + }), + ); + + it.effect( + "should be able to fall back to a different program", + ({ expect }) => + Effect.gen(function* () { + const command = Command.make("custom-echo", "-n", "test"); + const result = yield* pipe( + command, + Command.string(), + Effect.catchTag("SystemError", (error) => { + if (error.reason === "NotFound") { + return Command.string(Command.make("echo", "-n", "test")); + } + return Effect.fail(error); + }), + ); + expect(result).toBe("test"); + }), + ); + + it.effect("should interrupt a process manually", ({ expect }) => + Effect.gen(function* () { + const command = Command.make("sleep", "20"); + const result = yield* pipe( + Effect.fork(Command.exitCode(command)), + Effect.flatMap((fiber) => Effect.fork(Fiber.interrupt(fiber))), + Effect.flatMap(Fiber.join), + ); + expect(Exit.isInterrupted(result)).toBe(true); + }), + ); + + it.effect( + "should interrupt a process due to a timeout", + ({ expect }) => + Effect.gen(function* () { + const command = pipe( + Command.make("sleep", "20"), + Command.exitCode, + Effect.timeout(5000), + ); + + const fiber = yield* Effect.fork(command); + const adjustFiber = yield* Effect.fork(TestClock.adjust(5000)); + + yield* Effect.sleep(5000); + + yield* Fiber.join(adjustFiber); + const output = yield* Fiber.join(fiber); + + expect(output).not.toEqual(0); + }), + { fails: true }, // Times out. + ); + + it.scoped("should capture stderr and stdout separately", ({ expect }) => + it.flakyTest( + Effect.gen(function* () { + const path = yield* Path.Path; + + const command = pipe( + Command.make("./duplex.sh"), + Command.workingDirectory(path.join(...TEST_BASH_SCRIPTS_PATH)), + ); + const process = yield* Command.start(command); + const result = yield* pipe( + process.stdout, + Stream.zip(process.stderr), + Stream.runCollect, + Effect.map((bytes) => { + const decoder = new TextDecoder("utf-8"); + return globalThis.Array.from(bytes).flatMap( + ([left, right]) => + [decoder.decode(left), decoder.decode(right)] as const, + ); + }), + ); + expect(result).toEqual(["stdout1\nstdout2\n", "stderr1\nstderr2\n"]); + }), + ), + ); + + it("should return non-zero exit code in success channel", ({ expect }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const command = pipe( + Command.make("./non-zero-exit.sh"), + Command.workingDirectory(path.join(...TEST_BASH_SCRIPTS_PATH)), + ); + const result = yield* Command.exitCode(command); + expect(result).toBe(1); + })); + + it.effect( + "should throw permission denied as a typed error", + ({ expect }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const command = pipe( + Command.make("./no-permissions.sh"), + Command.workingDirectory(path.join(...TEST_BASH_SCRIPTS_PATH)), + ); + const result = yield* Effect.exit(Command.string(command)); + expect(result).toEqual( + Exit.fail( + SystemError({ + reason: "PermissionDenied", + module: "Command", + method: "spawn", + pathOrDescriptor: "./no-permissions.sh ", + syscall: "spawn ./no-permissions.sh", + message: "spawn ./no-permissions.sh EACCES", + }), + ), + ); + }), + { fails: true }, // Deno has worse errors. + ); + + it.effect( + "should throw non-existent working directory as a typed error", + ({ expect }) => + Effect.gen(function* () { + const command = pipe( + Command.make("ls"), + Command.workingDirectory("/some/bad/path"), + ); + const result = yield* Effect.exit(Command.lines(command)); + expect(result).toEqual( + Exit.fail( + SystemError({ + reason: "NotFound", + module: "FileSystem", + method: "access", + pathOrDescriptor: "/some/bad/path", + syscall: "access", + message: + "ENOENT: no such file or directory, access '/some/bad/path'", + }), + ), + ); + }), + { fails: true }, // Deno has worse errors. + ); + + it("should be able to kill a running process", ({ expect }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const command = pipe( + Command.make("./repeat.sh"), + Command.workingDirectory(path.join(...TEST_BASH_SCRIPTS_PATH)), + ); + const process = yield* Command.start(command); + const isRunningBeforeKill = yield* process.isRunning; + yield* process.kill(); + const isRunningAfterKill = yield* process.isRunning; + expect(isRunningBeforeKill).toBe(true); + expect(isRunningAfterKill).toBe(false); + })); + + it.effect( + "should support piping commands together", + ({ expect }) => + Effect.gen(function* () { + const command = pipe( + Command.make("echo", "2\n1\n3"), + Command.pipeTo(Command.make("cat")), + Command.pipeTo(Command.make("sort")), + ); + const result = yield* Command.lines(command); + expect(result).toEqual(["1", "2", "3"]); + }), + { fails: true }, // Times out. + ); + + it.effect( + "should ensure that piping commands is associative", + ({ expect }) => + Effect.gen(function* () { + const command = pipe( + Command.make("echo", "2\n1\n3"), + Command.pipeTo(Command.make("cat")), + Command.pipeTo(Command.make("sort")), + Command.pipeTo(Command.make("head", "-2")), + ); + const lines1 = yield* Command.lines(command); + const lines2 = yield* Command.lines(command); + expect(lines1).toEqual(["1", "2"]); + expect(lines2).toEqual(["1", "2"]); + }), + { fails: true }, // Times out. + ); + + it.effect( + "should allow stdin on a piped command", + ({ expect }) => + Effect.gen(function* () { + const encoder = new TextEncoder(); + const command = pipe( + Command.make("cat"), + Command.pipeTo(Command.make("sort")), + Command.pipeTo(Command.make("head", "-2")), + Command.stdin(Stream.make(encoder.encode("2\n1\n3"))), + ); + const result = yield* Command.lines(command); + expect(result).toEqual(["1", "2"]); + }), + { fails: true }, // Times out. + ); + + it("should delegate env to all commands", ({ expect }) => { + const env = { key: "value" }; + const command = pipe( + Command.make("cat"), + Command.pipeTo(Command.make("sort")), + Command.pipeTo(Command.make("head", "-2")), + Command.env(env), + ); + const envs = Command.flatten(command).map((command) => + Object.fromEntries(command.env), + ); + expect(envs).toEqual([env, env, env]); + }); + + it("should delegate workingDirectory to all commands", ({ expect }) => { + const workingDirectory = "working-directory"; + const command = pipe( + Command.make("cat"), + Command.pipeTo(Command.make("sort")), + Command.pipeTo(Command.make("head", "-2")), + Command.workingDirectory(workingDirectory), + ); + const directories = Command.flatten(command).map((command) => command.cwd); + expect(directories).toEqual([ + Option.some(workingDirectory), + Option.some(workingDirectory), + Option.some(workingDirectory), + ]); + }); + + it("should delegate stderr to the right-most command", ({ expect }) => { + const command = pipe( + Command.make("cat"), + Command.pipeTo(Command.make("sort")), + Command.pipeTo(Command.make("head", "-2")), + Command.stderr("inherit"), + ); + const stderr = Command.flatten(command).map((command) => command.stderr); + expect(stderr).toEqual(["pipe", "pipe", "inherit"]); + }); + + it("should delegate stdout to the right-most command", ({ expect }) => { + const command = pipe( + Command.make("cat"), + Command.pipeTo(Command.make("sort")), + Command.pipeTo(Command.make("head", "-2")), + Command.stdout("inherit"), + ); + const stdout = Command.flatten(command).map((command) => command.stdout); + expect(stdout).toEqual(["pipe", "pipe", "inherit"]); + }); + + it.scoped("exitCode after exit", ({ expect }) => + Effect.gen(function* () { + const command = Command.make("echo", "-n", "test"); + const process = yield* Command.start(command); + yield* process.exitCode; + const code = yield* process.exitCode; + expect(code).toEqual(0); + }), + ); + + it.scoped( + "should allow running commands in a shell", + ({ expect }) => + Effect.gen(function* () { + const files = ["foo.txt", "bar.txt", "baz.txt"]; + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const tempDir = yield* fileSystem.makeTempDirectoryScoped(); + yield* pipe( + Effect.forEach( + files, + (file) => + fileSystem.writeFile(path.join(tempDir, file), new Uint8Array()), + { discard: true }, + ), + ); + const command = Command.make("compgen", "-f").pipe( + Command.workingDirectory(tempDir), + Command.runInShell("/bin/bash"), + ); + const lines = yield* Command.lines(command); + expect(Array.sort(files, Order.string)).toEqual( + Array.sort(lines, Order.string), + ); + }), + { fails: true }, // Doesn't seem to run in a shell, gets 'Failed to spawn... entity not found'. + ); +}); diff --git a/packages/platform-deno/tests/fixtures/bash/duplex.sh b/packages/platform-deno/tests/fixtures/bash/duplex.sh new file mode 100755 index 0000000..249a4de --- /dev/null +++ b/packages/platform-deno/tests/fixtures/bash/duplex.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +function echoerr() { + echo "$@" 1>&2 +} + +echo "stdout1" +echoerr "stderr1" + +echo "stdout2" +echoerr "stderr2" diff --git a/packages/platform-deno/tests/fixtures/bash/no-permissions.sh b/packages/platform-deno/tests/fixtures/bash/no-permissions.sh new file mode 100644 index 0000000..2d7f858 --- /dev/null +++ b/packages/platform-deno/tests/fixtures/bash/no-permissions.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +echo "this should not run because it doesn't have execute permissions" diff --git a/packages/platform-deno/tests/fixtures/bash/non-zero-exit.sh b/packages/platform-deno/tests/fixtures/bash/non-zero-exit.sh new file mode 100755 index 0000000..f019ff9 --- /dev/null +++ b/packages/platform-deno/tests/fixtures/bash/non-zero-exit.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +exit 1 diff --git a/packages/platform-deno/tests/fixtures/bash/repeat.sh b/packages/platform-deno/tests/fixtures/bash/repeat.sh new file mode 100755 index 0000000..3333fd9 --- /dev/null +++ b/packages/platform-deno/tests/fixtures/bash/repeat.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +for i in {1..60}; do + echo "iteration: $i" + sleep 1 +done