├── .gitignore ├── LICENSE ├── README.md ├── deno.d.ts ├── deno.ts ├── examples ├── middlewares.ts └── showcase.ts ├── mock.ts ├── mod.ts ├── src ├── consumer.ts ├── middleware.ts ├── mod.ts └── provider.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2021 Minigugus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

BAZX

3 | 4 | > 🐚️ [zx](https://github.com/google/zx) on 💊️ steroids 5 |
6 | 7 | > You are seeing the code of an upcoming release. The `main` branch contains the latest released code. 8 | > Code on this branch is still under discussion and documentation is not completed yet. 9 | 10 | ## Main differences with ZX 11 | 12 | * Written from scratch 13 | * **0 dependencies** by default 14 | * **Plateform-agnostic** (not limited to *Deno* or *NodeJS*) 15 | * Extensible (enables **remote command execution** and thus **browser support**) 16 | * **Middlewares** support (filter allowed commands, hack input/outputs, and much more) 17 | * [**Streams**](#streams) at the core for better performances ([no streaming support with zx](https://github.com/google/zx/issues/14#issuecomment-841672494)) 18 | * No shell by default (the `|` in ``$`echo |`;`` is not evaluated by default) 19 | * Modern (TypeScript, Deno, ES2020) 20 | * **MIT** vs Apache license 21 | * Library only 22 | 23 | ## Support 24 | 25 | * Deno: 🐥️ (working) 26 | * NodeJS: 🥚️ (not started yet) 27 | * QuickJS: 🥚️ (not started yet) 28 | * Browser: 🥚️ (not started yet) 29 | * Mock: 🐣️ (started) 30 | 31 | ## Setup 32 | 33 | ### Deno 34 | 35 | ```js 36 | import $ from 'https://deno.land/x/bazx/deno.ts'; 37 | ``` 38 | 39 | As of now, only the `--allow-run` command is required at runtime. 40 | 41 | ### Isomorphic (for testing purpose) 42 | 43 | ```js 44 | import $ from 'https://deno.land/x/bazx/mock.ts'; 45 | ``` 46 | 47 | This implementation doesn't know how to spawn a process and thus always throw. 48 | Intended to be used along with middlewares in tests or sandboxes for instance. 49 | 50 | ### (Bring Your Own Runtime) 51 | 52 | ```ts 53 | import { createBazx } from 'https://deno.land/x/bazx/mod.ts'; 54 | 55 | const $ = createBazx(( 56 | cmd: [string, ...string[]], 57 | options: { 58 | cwd?: string, 59 | env?: Record, 60 | stdin?: ReadableStream, 61 | stdout?: WritableStream, 62 | stderr?: WritableStream, 63 | signal?: AbortSignal, 64 | } 65 | ): PromiseLike<{ code: number }> => { 66 | // Spawn commands here. 67 | // 68 | // CAUTION: This function is reponsible for closing stdin/stdout/stderr. 69 | // Missing to do so will result in deadlocks. 70 | }, { /* Default options (logging, colors, and so on) */ }); 71 | ``` 72 | 73 | ## Usage 74 | 75 | See the [`examples`](examples/) folder for more examples 76 | 77 | ### Middlewares 78 | 79 | Middlewares are hooks that runs a process get spawned, so that it can for instance 80 | dynamically hack streams, change the command line, working directories and environment 81 | variabes, never really spawn a process, spawn a process twice, and so on. 82 | 83 | For instance, this really simple middleware wraps processes with `time`, so that some process meta are displayed: 84 | 85 | ```typescript 86 | import { BazxMiddleware } from 'https://deno.land/x/bazx/mod.ts'; 87 | 88 | export const timeWrapperMiddleware: BazxMiddleware = 89 | exec => (cmd, options) => exec(['time', ...cmd], options); 90 | ``` 91 | 92 | Then, it can be applied with the `$.with` function: 93 | 94 | ```typescript 95 | import $ from 'https://deno.land/x/bazx/deno.ts'; 96 | 97 | const $$ = $.with(timeWrapperMiddleware); 98 | 99 | await $$`echo Hello world!`.status 100 | 101 | // $ echo Hello world! 102 | // Hello world! 103 | // 0.00user 0.00system 0:00.00elapsed 100%CPU (0avgtext+0avgdata 1936maxresident)k 104 | // 0inputs+0outputs (0major+73minor)pagefaults 0swaps 105 | ``` 106 | 107 | As you can may have noticed, only the original command is printed to the user, 108 | not the updated one with `time`. This way, middlewares are fully transparents to the user. 109 | 110 | Each `.with` call returns a new `$` instance that use the config of the parent (`exec`, `options` and middlewares), 111 | so that multiple `.with` calls can be chained. 112 | 113 | ### TODO 114 | 115 | * [ ] Improve docs (README + JSDoc) 116 | * [ ] Rollup for NodeJS and browser builds 117 | * [ ] Add more runtime support (NodeJS at least) 118 | * [ ] Fix bugs (some complex use case doesn't work yet) 119 | * [X] Dynamic config update (like `set` in bash (enable/disable logs, etc.)) 120 | * [X] `NO_COLOR` support (for CI/CD) 121 | * [ ] Pipelines starting with a stream 122 | * [ ] `stderr` pipes 123 | * [ ] Add benchmarks, improve perfs (audit WHATWG streams perfs) 124 | * [ ] Discuss envrionment variables support 125 | 126 | ## FAQ 127 | 128 | ### Why is it called `bazx`? 129 | 130 | Just like `bash` built from `sh`, there should be a `bazx` built from `zx` 😁️ 131 | 132 | ### How do you prononce `bazx`? 133 | 134 | *basix* (just like *basic* but with a *x* instead of a *c* at the end) 135 | 136 | ## License 137 | 138 | Inspired by [zx](https://github.com/google/zx) 139 | 140 | [MIT License](LICENSE) 141 | 142 | Copyright 2021 Minigugus 143 | -------------------------------------------------------------------------------- /deno.ts: -------------------------------------------------------------------------------- 1 | // Bazx implementation for Deno. 2 | 3 | /// 4 | 5 | export * from './mod.ts' 6 | 7 | import type { BazxExec, BazxOptions } from './mod.ts'; 8 | 9 | import { createBazx } from './mod.ts'; 10 | 11 | function streamCopyToStream(reader: Deno.Reader & Deno.Closer) { 12 | const buffer = new Uint8Array(16_640); 13 | return new ReadableStream({ 14 | async pull(controller) { 15 | let read; 16 | try { 17 | while (controller.desiredSize! > 0) { 18 | if ((read = await reader.read(buffer.subarray(0, Math.min(buffer.byteLength, controller.desiredSize ?? Number.MAX_VALUE)))) === null) { 19 | reader.close(); 20 | controller.close(); 21 | return; 22 | } 23 | controller.enqueue(buffer.slice(0, read)); 24 | } 25 | } catch (err) { 26 | if (!(err instanceof Deno.errors.BadResource)) 27 | controller.error(err); 28 | else 29 | controller.close(); 30 | } 31 | }, 32 | cancel() { 33 | reader.close(); 34 | } 35 | }, new ByteLengthQueuingStrategy({ 36 | highWaterMark: 16640 37 | })); 38 | } 39 | 40 | async function pipeReadableStream2Writer( 41 | readable: ReadableStream, 42 | writer: Deno.Writer & Deno.Closer 43 | ) { 44 | const reader = readable.getReader(); 45 | try { 46 | let read: ReadableStreamReadResult; 47 | while (!(read = await reader.read()).done) 48 | if (!await writer.write(read.value!)) 49 | break; 50 | await reader.cancel(); 51 | } catch (err) { 52 | if (err instanceof Deno.errors.BrokenPipe) 53 | await reader.releaseLock(); 54 | else 55 | await reader.cancel(err); 56 | } finally { 57 | try { 58 | writer.close(); 59 | } catch (ignored) { } 60 | } 61 | } 62 | 63 | export const exec: BazxExec = async function exec(cmd, { 64 | cwd, 65 | env, 66 | stdin, 67 | stdout, 68 | stderr, 69 | signal 70 | } = {}) { 71 | const process = Deno.run({ 72 | cmd, 73 | cwd, 74 | env, 75 | stdin: stdin ? 'piped' : 'null', 76 | stdout: stdout ? 'piped' : 'null', 77 | stderr: stderr ? 'piped' : 'null', 78 | }); 79 | signal?.addEventListener('abort', () => process.kill?.(9), { once: true }); 80 | try { 81 | const [{ code, signal: exitSignal }] = await Promise.all([ 82 | process.status(), 83 | stdin && pipeReadableStream2Writer(stdin, process.stdin!), 84 | stdout && streamCopyToStream(process.stdout!).pipeTo(stdout), 85 | stderr && streamCopyToStream(process.stderr!).pipeTo(stderr), 86 | ]); 87 | return { code, signal: exitSignal }; 88 | } finally { 89 | process.close(); 90 | } 91 | } 92 | 93 | export const options: BazxOptions = { 94 | highWaterMark: 16640, 95 | noColors: Deno.noColor, 96 | log: chunk => Deno.stdout.writeSync(chunk) 97 | }; 98 | 99 | export const $ = createBazx(exec, options); 100 | 101 | export default $; 102 | 103 | Deno.test({ 104 | name: 'everything works', 105 | async fn() { 106 | // @ts-ignore 107 | const assert: (expr: unknown, msg: string) => asserts expr = (await import("https://deno.land/std/testing/asserts.ts")).assert; 108 | // @ts-ignore 109 | const { fail, assertEquals } = await import("https://deno.land/std/testing/asserts.ts"); 110 | 111 | const cmd = $`bash -c ${'echo Hello world! $(env | grep WTF) $(pwd)'}` 112 | .cwd('/bin') 113 | .pipe(new TransformStream({ 114 | start(controller) { 115 | controller.enqueue(new TextEncoder().encode('Someone said: ')); 116 | } 117 | })) 118 | .env('WTF', 'it works') 119 | .pipe($`sh -c ${'cat - $(realpath $(env | grep WTF))'}`) 120 | .env('WTF', 'not_found') 121 | .cwd('/'); 122 | 123 | try { 124 | await cmd.text(); 125 | fail("Should have thrown since cat should have failed"); 126 | } catch (err) { 127 | assertEquals(`Command ${cmd.command} exited with code 1`, err.message); 128 | assert(err.response instanceof Response, "err.response is defined and is an instance of Response"); 129 | assertEquals('Someone said: Hello world! WTF=it works /bin\n', await err.response.text()); 130 | } 131 | } 132 | }); 133 | -------------------------------------------------------------------------------- /examples/middlewares.ts: -------------------------------------------------------------------------------- 1 | import $, { filtered } from '../deno.ts'; 2 | 3 | let $safe = $ 4 | .with(filtered( 5 | cmd => ['echo'].includes(cmd[0]) // only the `echo` command is allowed to run 6 | )) 7 | 8 | let text = await $safe`echo Hello world!`.text() 9 | 10 | let alwaysNull = null; 11 | try { 12 | alwaysNull = await $safe`rm -rf /`.status 13 | } catch (err) { 14 | console.error(err); 15 | } 16 | 17 | let mixed$ = await $safe`echo From echo` // using $safe 18 | .pipe($`sh -c ${'cat >&2; echo From sh'}`) // using $ 19 | .text() 20 | 21 | // advanced usage 22 | import { interceptCurl, swapStdoutStderr, shWrapper } from '../deno.ts'; 23 | 24 | let $$ = $ 25 | .with(shWrapper) // last invoked 26 | .with(interceptCurl) // first invoked 27 | 28 | let $swap = $$ 29 | .with(swapStdoutStderr) 30 | 31 | let advanced = await ( 32 | await $safe`echo ${new Date().toISOString()}` // passed as input to `curl` below 33 | .pipe($$`curl http://detectportal.firefox.com/success.txt`) 34 | .env('Content-Type', 'text/plain') // passed to `curl` above 35 | 36 | // transform `curl` output 37 | .pipe(new TransformStream({ 38 | transform(chunk, controller) { 39 | for (let index = 0; index < chunk.length; index++) 40 | if (chunk[index] === 10) 41 | break; 42 | else 43 | chunk[index]++; 44 | controller.enqueue(chunk); 45 | } 46 | })) 47 | 48 | // >&2 works thanks to `shWrapper` and is required to print to stdout because of `swapStdoutStderr` 49 | .pipe($swap`cat - /notfound >&2`) 50 | ).text() // not calling `.text()` directly to avoid an error with `cat` that exit with code 1 51 | 52 | console.table({ 53 | text, 54 | alwaysNull, 55 | mixed$, 56 | advanced 57 | }) 58 | -------------------------------------------------------------------------------- /examples/showcase.ts: -------------------------------------------------------------------------------- 1 | import $ from '../deno.ts'; 2 | 3 | let response = await $`echo Hi!`; 4 | 5 | let text = await $`echo Hello world!`.text() 6 | 7 | let [json] = await $`echo ${JSON.stringify(["Hello world!"])}`.json() 8 | 9 | let buffer = await $`echo Hello world!` 10 | .pipe($`gzip`) 11 | .arrayBuffer() 12 | 13 | let env = await $`env` // sees `WTF_A=wtf`, `WTF_B=b` 14 | .env('WTF_A', 'erased') 15 | .env('WTF_A', 'wtf') 16 | .pipe($`sh -c ${ // sees `WTF_A=a`, `WTF_B=b` 17 | 'echo \\"env\\" sees: >&2; grep WTF; echo \\"sh\\" sees: >&2; env | grep WTF' 18 | }`) 19 | .env('WTF_A', 'a') 20 | .env('WTF_B', 'b') 21 | .text() 22 | 23 | let cwd = await $`pwd` // runs in `/bin` 24 | .cwd('/root') 25 | .cwd('/bin') 26 | .pipe($`sh -c ${'cat; pwd'}`) // runs in `/` 27 | .cwd('/') 28 | .text() 29 | 30 | let status = await $`sh -c ${'exit 42'}`.status 31 | 32 | let throws; 33 | try { 34 | throws = await $`grep nothing`.text() 35 | } catch (err) { 36 | if (!(err?.response instanceof Response)) 37 | throw err; 38 | throws = `Nothing found (exit code: ${err.response.status})` 39 | } 40 | 41 | console.table({ 42 | text, 43 | json, 44 | buffer: String(buffer), 45 | env, 46 | cwd, 47 | status, 48 | throws, 49 | response: String(response), 50 | }); 51 | -------------------------------------------------------------------------------- /mock.ts: -------------------------------------------------------------------------------- 1 | // Isomorphic bazx implementation that never runs a command (always throw). 2 | // Intended to be used along with middlewares. 3 | 4 | /// 5 | 6 | export * from './mod.ts' 7 | 8 | import type { BazxExec, BazxOptions } from './mod.ts'; 9 | 10 | import { createBazx } from './mod.ts'; 11 | 12 | export const exec: BazxExec = async function exec(cmd, { stdin, stdout, stderr } = {}) { 13 | await Promise.all([ 14 | stdin?.cancel(), 15 | stdout?.getWriter().close(), 16 | stderr?.getWriter().close() 17 | ]); 18 | throw new Error(`${cmd[0]}: command not found`); // FIXME Return code 0 instead? 19 | } 20 | 21 | export const options: BazxOptions = {}; 22 | 23 | export const $ = createBazx(exec, options); 24 | 25 | export default $; 26 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | // This file expose isomorphic code only. 2 | // Deno users are intented to import the `deno.ts` file instead. 3 | 4 | export * from './src/mod.ts'; 5 | -------------------------------------------------------------------------------- /src/consumer.ts: -------------------------------------------------------------------------------- 1 | import { BazxExecConfig, BazxExec } from './provider.ts'; 2 | 3 | function getStrategyFromContext(context: BazxOptions) { 4 | if (context.highWaterMark) 5 | return new ByteLengthQueuingStrategy({ 6 | highWaterMark: context.highWaterMark 7 | }); 8 | } 9 | 10 | const ENCODER = new TextEncoder(); 11 | 12 | const createStdoutLogger = (context: BazxOptions) => context.log ? new WritableStream({ 13 | async write(chunk) { 14 | await context.log!(chunk); 15 | } 16 | }) : undefined; 17 | 18 | const createStderrLogger = (context: BazxOptions) => { 19 | if (!context.log) 20 | return undefined; 21 | if (context.noColors) 22 | return new WritableStream({ 23 | async write(chunk) { 24 | await context.log!(chunk); 25 | } 26 | }); 27 | const redBuffer = ENCODER.encode('\x1B[31m'); 28 | const resetBuffer = ENCODER.encode('\x1B[0m'); 29 | return new WritableStream({ 30 | async write(chunk) { 31 | const buffer = new Uint8Array(redBuffer.byteLength + chunk.byteLength + resetBuffer.byteLength); 32 | buffer.set(redBuffer, 0); 33 | buffer.set(chunk, redBuffer.byteLength); 34 | buffer.set(resetBuffer, redBuffer.byteLength + chunk.byteLength); 35 | await context.log!(buffer); 36 | } 37 | }); 38 | }; 39 | 40 | function mergedWritableStream(writer: WritableStreamDefaultWriter, done: (err?: any) => void) { 41 | return new WritableStream({ 42 | async write(chunk) { 43 | await writer!.write(chunk); 44 | }, 45 | async abort(err) { 46 | await done(err); 47 | }, 48 | async close() { 49 | await done(); 50 | } 51 | }); 52 | } 53 | 54 | async function exec2StdoutResponse( 55 | cmd: string, 56 | cwd: string | undefined, 57 | env: Record, 58 | context: BazxOptions, 59 | invoke: (config?: BazxExecConfig) => ReturnType 60 | ) { 61 | const strategy = getStrategyFromContext(context); 62 | const output = new TransformStream( 63 | context.log ? { 64 | async transform(chunk, controller) { 65 | await context.log!(chunk); // Cheap logging hack 66 | controller.enqueue(chunk); 67 | } 68 | } : {}, 69 | strategy, 70 | strategy 71 | ); 72 | const [{ code }, result] = await Promise.all([ 73 | invoke({ 74 | cwd, 75 | env, 76 | stdout: output.writable, 77 | stderr: createStderrLogger(context) 78 | }), 79 | new Response(output.readable).blob() 80 | ]); 81 | return new CommandResponse( 82 | cmd, 83 | env, 84 | code, 85 | result 86 | ); 87 | } 88 | 89 | class Command implements Body, PromiseLike { 90 | #context: BazxOptions; 91 | 92 | #fetch: (config?: BazxExecConfig) => ReturnType; 93 | #cmd: (colors: boolean) => string; 94 | #cwd: string | undefined = undefined; 95 | #env: Record = Object.create(null); 96 | 97 | public constructor( 98 | context: BazxOptions, 99 | cmd: (colors: boolean) => string, 100 | fetch: (config?: BazxExecConfig) => ReturnType 101 | ) { 102 | this.#context = context; 103 | this.#fetch = fetch; 104 | this.#cmd = cmd; 105 | } 106 | 107 | then( 108 | onfulfilled?: (value: Response) => TResult1 | PromiseLike, 109 | onrejected?: (reason: any) => TResult2 | PromiseLike 110 | ): PromiseLike { 111 | return this.stdout.then(onfulfilled, onrejected); 112 | } 113 | 114 | get body(): ReadableStream { 115 | const buffer = new TransformStream(); 116 | this.stdout 117 | .then(rejectOnNonZeroExitCode) 118 | .then(response => response.body!.pipeTo(buffer.writable)) 119 | .catch(err => buffer.writable.abort(err).catch(() => { /* Prevent unhandled promise rejection */ })); 120 | return buffer.readable; 121 | } 122 | 123 | get bodyUsed(): boolean { 124 | return false; // Each call to `body` creates a new stream, so the body is never "used" 125 | } 126 | 127 | arrayBuffer(): Promise { 128 | return this.stdout 129 | .then(rejectOnNonZeroExitCode) 130 | .then(response => response.arrayBuffer()); 131 | } 132 | 133 | blob(): Promise { 134 | return this.stdout 135 | .then(rejectOnNonZeroExitCode) 136 | .then(response => response.blob()); 137 | } 138 | 139 | formData(): Promise { 140 | return this.stdout 141 | .then(rejectOnNonZeroExitCode) 142 | .then(response => response.formData()); 143 | } 144 | 145 | json(): Promise { 146 | return this.stdout 147 | .then(rejectOnNonZeroExitCode) 148 | .then(response => response.json()); 149 | } 150 | 151 | text(): Promise { 152 | return this.stdout 153 | .then(rejectOnNonZeroExitCode) 154 | .then(response => response.text()); 155 | } 156 | 157 | get url() { 158 | return this.command; 159 | } 160 | 161 | get command() { 162 | return this.#cmd(false); 163 | } 164 | 165 | cwd(path: string | null): Command { 166 | this.#cwd = path ?? undefined; 167 | return this; 168 | } 169 | 170 | env(key: string, value: string | null): Command { 171 | if (value === null) 172 | delete this.#env[key]; 173 | else 174 | this.#env[key] = value; 175 | return this; 176 | } 177 | 178 | get ok() { 179 | return this.status 180 | .then((code) => code === 0); 181 | } 182 | 183 | get status(): Promise { 184 | return Promise.resolve(this.#context.log?.(ENCODER.encode(`$ ${this}\r\n`))) 185 | .then(() => this.#fetch({ 186 | cwd: this.#cwd, 187 | env: this.#env, 188 | stdout: createStdoutLogger(this.#context), 189 | stderr: createStderrLogger(this.#context) 190 | })) 191 | .then(({ code }) => code); 192 | } 193 | 194 | get stdout(): Promise { 195 | return Promise.resolve(this.#context.log?.(ENCODER.encode(`$ ${this}\r\n`))) 196 | .then(() => exec2StdoutResponse( 197 | this.#cmd(false), 198 | this.#cwd, 199 | this.#env, 200 | this.#context, 201 | this.#fetch 202 | )); 203 | } 204 | 205 | toString() { 206 | return this.#cmd(!this.#context.noColors); 207 | } 208 | 209 | pipe(command: Command): Command; 210 | pipe(transformStream: TransformStream): Command; 211 | pipe(commandOrTransformStream: Command | TransformStream): Command; 212 | pipe(commandOrTransformStream: Command | TransformStream) { 213 | if (commandOrTransformStream instanceof Command) { 214 | const other = commandOrTransformStream; 215 | return new Command( 216 | this.#context, 217 | colors => { 218 | const left = this.#cmd(colors); 219 | const right = other.#cmd(colors); 220 | return !(left && right) 221 | ? left || right 222 | : colors 223 | ? `${left} \x1B[35m|\x1B[0m ${right}` 224 | : `${left} | ${right}`; 225 | }, 226 | async ({ cwd, env = {}, stdin, stdout, stderr, signal } = {}) => { 227 | const strategy = getStrategyFromContext(this.#context); 228 | const pipe = new TransformStream({}, strategy, strategy); 229 | const writer = stderr?.getWriter(); 230 | let left = 2; 231 | const [, result] = await Promise.all([ 232 | this.#fetch({ 233 | cwd: this.#cwd ?? cwd, 234 | env: Object.assign({}, env, this.#env), 235 | stdin, 236 | stdout: pipe.writable, 237 | stderr: !writer ? undefined : mergedWritableStream(writer, () => (--left || writer.close())), 238 | signal 239 | }), 240 | other.#fetch({ 241 | cwd: other.#cwd ?? cwd, 242 | env: Object.assign({}, env, other.#env), 243 | stdin: pipe.readable, 244 | stdout, 245 | stderr: !writer ? undefined : mergedWritableStream(writer, () => (--left || writer.close())), 246 | signal 247 | }) 248 | ]); 249 | return result; 250 | }); 251 | } 252 | const stream = commandOrTransformStream; 253 | return new Command( 254 | this.#context, 255 | colors => { 256 | const left = this.#cmd(colors); 257 | const right = colors ? `\x1B[33m${stream}\x1B[0m` : String(stream); 258 | return !left 259 | ? right 260 | : colors 261 | ? `${left} \x1B[35m|\x1B[0m ${right}` 262 | : `${left} | ${right}`; 263 | }, 264 | async ({ cwd, env = {}, stdin, stdout, stderr, signal } = {}) => { 265 | const [result] = await Promise.all([ 266 | this.#fetch({ 267 | cwd: this.#cwd ?? cwd, 268 | env: Object.assign({}, env, this.#env), 269 | stdin, 270 | stdout: stream.writable, 271 | stderr, 272 | signal 273 | }), 274 | stream.readable.pipeTo(stdout || new WritableStream({})) 275 | ]); 276 | return result; 277 | }); 278 | } 279 | } 280 | 281 | class CommandResponse extends Response { 282 | #url: string; 283 | #status: number; 284 | 285 | public constructor( 286 | cmd: string, 287 | env: Record, 288 | status: number, 289 | stdout: Blob 290 | ) { 291 | super(stdout, { 292 | headers: env 293 | }); 294 | this.#url = cmd; 295 | this.#status = status; 296 | } 297 | 298 | // @ts-ignore 299 | get url() { 300 | return this.#url; 301 | } 302 | 303 | // @ts-ignore 304 | get status() { 305 | return this.#status; 306 | } 307 | 308 | // @ts-ignore 309 | get ok() { 310 | return this.#status === 0; 311 | } 312 | } 313 | 314 | function rejectOnNonZeroExitCode(response: Response) { 315 | if (!response.ok) 316 | return Promise.reject( 317 | Object.assign( 318 | new Error(`Command ${response.url} exited with code ${response.status}`), 319 | { response } 320 | ) 321 | ); 322 | return Promise.resolve(response); 323 | } 324 | 325 | function parse(xs: TemplateStringsArray, ...args: any[]) { 326 | if (!Array.isArray(xs) || !Array.isArray(xs.raw)) 327 | throw new Error('$ can only be used as a template string tag'); 328 | const cmd: string[] = []; 329 | let left = ''; 330 | let i = 0; 331 | for (let part of xs) { 332 | for (let index; (index = part.indexOf(' ')) !== -1; part = part.slice(index + 1)) { 333 | left += part.slice(0, index); 334 | if (left) 335 | cmd.push(left); 336 | left = ''; 337 | } 338 | left += part; 339 | left += i === args.length ? '' : args[i++]; 340 | } 341 | if (left) 342 | cmd.push(left); 343 | // const cmd = (xs[0]! + args.map((x, i) => x + xs[i + 1]!).join('')).split(' ').filter(x => x); // FIXME 344 | if (cmd.length < 1) 345 | throw new Error('Missing command name'); 346 | return cmd as [string, ...string[]]; 347 | } 348 | 349 | export interface BazxOptions { 350 | highWaterMark?: number; 351 | noColors?: boolean; 352 | log?(chunk: Uint8Array): unknown | Promise; 353 | } 354 | 355 | export interface $ { 356 | (xs: TemplateStringsArray, ...args: any[]): Command; 357 | 358 | with(middleware: (exec: BazxExec) => BazxExec): $; 359 | } 360 | 361 | export function createBazx(exec: BazxExec, options: BazxOptions = {}): $ { 362 | function $(xs: TemplateStringsArray, ...args: any[]) { 363 | if (args.length === 0 && xs[0]?.length === 0) 364 | return new Command(options, () => '', async ({ stdin, stdout, stderr } = {}) => { 365 | await Promise.all([ 366 | stdin?.cancel(), 367 | stdout?.getWriter().close(), 368 | stderr?.getWriter().close() 369 | ]); 370 | return ({ code: 0, signal: undefined }) 371 | }); 372 | const parsed = parse(xs, ...args); 373 | return new Command( 374 | options, 375 | colors => colors 376 | ? `\x1B[32m${parsed[0]}\x1B[0m ${parsed.slice(1).map(x => `\x1B[4m${x}\x1B[0m`).join(' ')}`.trim() 377 | : parsed.join(' '), 378 | config => exec(parsed, config) 379 | ); 380 | } 381 | 382 | function withMiddleware(middleware: (exec: BazxExec) => BazxExec): $ { 383 | return createBazx(middleware(exec), options); 384 | } 385 | 386 | return Object.assign($, { 387 | with: withMiddleware 388 | }); 389 | } 390 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { BazxExec } from './provider.ts'; 2 | 3 | export type BazxMiddleware = (exec: BazxExec) => BazxExec; 4 | 5 | export function filtered(predicate: (cmd: [string, ...string[]]) => boolean): BazxMiddleware { 6 | return exec => function filteredExec(cmd, options) { 7 | if (!predicate(cmd)) 8 | throw new Error(`${cmd[0]}: not allowed to execute`); 9 | return exec(cmd, options); 10 | } 11 | } 12 | 13 | export const swapStdoutStderr: BazxMiddleware = exec => (cmd, options = {}) => exec(cmd, { 14 | ...options, 15 | stdout: options?.stderr, // stdout becomes stderr 16 | stderr: options?.stdout // stderr becomes stdout 17 | }); 18 | 19 | export const shWrapper: BazxMiddleware = exec => (cmd, options) => 20 | exec( 21 | [ 22 | 'sh', 23 | '-c', 24 | cmd 25 | .map(a => a.includes(' ') ? `"${a}"` : a) 26 | .join(' ') 27 | ], 28 | options 29 | ); 30 | 31 | export const interceptCurl: BazxMiddleware = exec => async (cmd, options = {}) => { 32 | if (cmd[0] !== 'curl') 33 | return exec(cmd, options) 34 | await options.stderr?.abort(); // middleware MUST ensure that all streams in `options` are closed (deadlock otherwise) 35 | const response = await fetch( 36 | new URL(cmd[cmd.length - 1 || 1] ?? '', new URL(options.cwd || '.', 'file:///')).href, 37 | { 38 | method: options.stdin ? 'POST' : 'GET', 39 | headers: options.env, 40 | body: options.stdin 41 | } 42 | ); 43 | if (response.body) 44 | await response.body.pipeTo(options.stdout ?? new WritableStream()); 45 | else if (options.stdout) 46 | await options.stdout?.abort(); 47 | return { 48 | code: response.status - 200 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/mod.ts: -------------------------------------------------------------------------------- 1 | export * from './provider.ts' 2 | export * from './consumer.ts' 3 | export * from './middleware.ts' 4 | -------------------------------------------------------------------------------- /src/provider.ts: -------------------------------------------------------------------------------- 1 | export interface BazxExecConfig { 2 | cwd?: string; 3 | env?: Record; 4 | stdin?: ReadableStream; 5 | stdout?: WritableStream; 6 | stderr?: WritableStream; 7 | signal?: AbortSignal; 8 | } 9 | 10 | export type BazxExec = (cmd: [string, ...string[]], options?: BazxExecConfig) => Promise<{ code: number }>; 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "esnext", 5 | "strict": true, 6 | "noUncheckedIndexedAccess": true 7 | }, 8 | "include": [ 9 | "./*.ts", 10 | "src/*.ts", 11 | "examples/*.ts", 12 | ] 13 | } --------------------------------------------------------------------------------