Skip to content

Instantly share code, notes, and snippets.

@Qix-
Created April 7, 2021 17:31
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Qix-/2e58b362904ea066967d4a4b1b6e4db2 to your computer and use it in GitHub Desktop.
Save Qix-/2e58b362904ea066967d4a4b1b6e4db2 to your computer and use it in GitHub Desktop.
A thing I spent a few hours writing and ended up not needing.
/*
sh`emulator` (shell emulator)
by Josh Junon
Dual license: MIT License OR public domain
const [code, out, err] = await sh
`cat ${process.argv[2]}`
`wc -w`
`rev`
`tee /tmp/rev-word-count.txt`
console.log({code, out, err})
{
code: 0,
out: ' 42',
err: ''
}
Interpolation works as you'd expect; values are
considered contiguous strings (as though they
were double-quoted in shellscript).
*/
const child_process = require('child_process');
class StreamSink {
constructor() {
this.chunks = [];
this.totalLength = 0;
this.finishedBuffer = null;
this.endCbs = [];
this.streamCounts = 0;
}
attach(stream) {
++this.streamCounts;
stream.on('data', buf => {
this.chunks.push(buf);
this.totalLength += buf.length;
});
stream.on('end', () => {
if (--this.streamCounts === 0) {
this.finishedBuffer = Buffer.concat(this.chunks, this.totalLength);
this.endCbs.forEach(cb => cb(this.finishedBuffer));
delete this.endCbs; // Make any misuse or bugs fail loudly
}
});
return this;
}
/*async*/ getBuffer() {
return new Promise(resolve => {
if (this.finishedBuffer) {
return resolve(this.finishedBuffer);
}
this.endCbs.push(resolve);
});
}
};
const makeSh = (ctx = {}) => {
if (!('stderr' in ctx)) {
ctx.stderr = new StreamSink();
}
const newSh = (strings, ...args) => {
if (!Array.isArray(strings)) {
// strings is really a context;
// apply whatever changes are given
// and then return a new context.
return makeSh(Object.assign(
{}, ctx, strings
));
}
const argv = [];
let chunks = [];
const commit = () => {
if (chunks.length > 0) {
argv.push(chunks.join(''));
chunks = [];
}
};
strings[0] = strings[0].trimStart();
strings[strings.length - 1] = strings[strings.length - 1].trimEnd();
for (let i = 0, len = strings.length; i < len; i++) {
if (i > 0) {
const arg = args[i - 1];
chunks.push(arg.toString());
}
const string = strings[i];
const pattern = /([^ \t\r\n]+)|([ \t\r\n]+)/g;
let match;
while ((match = pattern.exec(string))) {
if (match[1]) {
chunks.push(match[1]);
} else {
commit();
}
}
}
commit();
if (argv.length === 0) {
throw new Error('no arguments given');
}
const argv0 = argv.shift();
const newCtx = Object.assign({}, ctx);
/*
A little note on the implementation:
Technically speaking, we could pass
the preceding process's standard
output here to the `stdio` property.
However, we'd have to wait for the
`spawn` event since there wouldn't be
a file descriptor guaranteed to back
the stream, thus the call to spawn
would fail.
Thus, we opt just to eat the copy cost
and pipe directly to the next process.
In theory, we could wait to spawn
processes upon `await` but that's a
refactor for another day.
Feel free to PR.
*/
const child = child_process.spawn(
argv0,
argv,
{
cwd: ctx.cwd || process.cwd(),
env: ctx.env || process.env,
argv0: ctx.argv0 || argv0,
stdio: [
(ctx.stdout ? 'pipe' : process.stdin),
'pipe',
'pipe'
]
}
);
let resolve;
let reject;
const promise = new Promise((resolve_, reject_) => {
resolve = resolve_;
reject = reject_;
});
newCtx.stdout = child.stdout;
newCtx.statusPromise = promise;
if (ctx.stdout) ctx.stdout.pipe(child.stdin); // otherwise, we use process.stdin above, which CAN be duped
const stdoutSink = new StreamSink().attach(child.stdout);
ctx.stderr.attach(child.stderr);
child.on('error', reject);
child.on('exit', code => resolve(code));
const result = makeSh(newCtx);
result.then = (...args) => (
promise
/*
If Javascript's array destructuring ignored
extra elements, we'd also pass the signal
as the fourth element. However I can't see it
being needed frequently enough to justify having
it in there otherwise.
*/
.then(async code => ([
(code || (ctx.statusPromise && await ctx.statusPromise) || 0),
await stdoutSink.getBuffer(),
await ctx.stderr.getBuffer()
]))
.then(...args)
);
result.catch = (...args) => promise.catch(...args);
return result;
};
newSh.out = (...args) => {
const shres = newSh(...args);
const result = (...args) => shres.out(...args);
result.then = (...args) => shres
.then(([code, stdout, stderr]) => {
process.stderr.write(stderr);
if (code !== 0) process.exit(code);
return stdout;
})
.then(...args);
result.catch = (...args) => shres.catch(...args);
return result;
};
return newSh;
};
module.exports = makeSh();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment