├── .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 |
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 | }
--------------------------------------------------------------------------------