mirror of
https://github.com/deployphp/action.git
synced 2024-11-30 07:19:03 +00:00
362 lines
12 KiB
JavaScript
362 lines
12 KiB
JavaScript
// Copyright 2021 Google LLC
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// https://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
import assert from 'node:assert';
|
|
import { spawn } from 'node:child_process';
|
|
import { AsyncLocalStorage, createHook } from 'node:async_hooks';
|
|
import { inspect } from 'node:util';
|
|
import chalk from 'chalk';
|
|
import which from 'which';
|
|
import { errnoMessage, exitCodeInfo, formatCmd, noop, parseDuration, psTree, quote, quotePowerShell, } from './util.js';
|
|
const processCwd = Symbol('processCwd');
|
|
const storage = new AsyncLocalStorage();
|
|
const hook = createHook({
|
|
init: syncCwd,
|
|
before: syncCwd,
|
|
promiseResolve: syncCwd,
|
|
after: syncCwd,
|
|
destroy: syncCwd,
|
|
});
|
|
hook.enable();
|
|
export const defaults = {
|
|
[processCwd]: process.cwd(),
|
|
verbose: true,
|
|
env: process.env,
|
|
shell: true,
|
|
prefix: '',
|
|
quote: () => {
|
|
throw new Error('No quote function is defined: https://ï.at/no-quote-func');
|
|
},
|
|
spawn,
|
|
log,
|
|
};
|
|
try {
|
|
defaults.shell = which.sync('bash');
|
|
defaults.prefix = 'set -euo pipefail;';
|
|
defaults.quote = quote;
|
|
}
|
|
catch (err) {
|
|
if (process.platform == 'win32') {
|
|
defaults.shell = which.sync('powershell.exe');
|
|
defaults.quote = quotePowerShell;
|
|
}
|
|
}
|
|
function getStore() {
|
|
return storage.getStore() || defaults;
|
|
}
|
|
export const $ = new Proxy(function (pieces, ...args) {
|
|
const from = new Error().stack.split(/^\s*at\s/m)[2].trim();
|
|
if (pieces.some((p) => p == undefined)) {
|
|
throw new Error(`Malformed command at ${from}`);
|
|
}
|
|
let resolve, reject;
|
|
const promise = new ProcessPromise((...args) => ([resolve, reject] = args));
|
|
let cmd = pieces[0], i = 0;
|
|
while (i < args.length) {
|
|
let s;
|
|
if (Array.isArray(args[i])) {
|
|
s = args[i].map((x) => $.quote(substitute(x))).join(' ');
|
|
}
|
|
else {
|
|
s = $.quote(substitute(args[i]));
|
|
}
|
|
cmd += s + pieces[++i];
|
|
}
|
|
promise._bind(cmd, from, resolve, reject, getStore());
|
|
// Postpone run to allow promise configuration.
|
|
setImmediate(() => promise.isHalted || promise.run());
|
|
return promise;
|
|
}, {
|
|
set(_, key, value) {
|
|
const target = key in Function.prototype ? _ : getStore();
|
|
Reflect.set(target, key, value);
|
|
return true;
|
|
},
|
|
get(_, key) {
|
|
const target = key in Function.prototype ? _ : getStore();
|
|
return Reflect.get(target, key);
|
|
},
|
|
});
|
|
function substitute(arg) {
|
|
if (arg?.stdout) {
|
|
return arg.stdout.replace(/\n$/, '');
|
|
}
|
|
return `${arg}`;
|
|
}
|
|
export class ProcessPromise extends Promise {
|
|
constructor() {
|
|
super(...arguments);
|
|
this._command = '';
|
|
this._from = '';
|
|
this._resolve = noop;
|
|
this._reject = noop;
|
|
this._snapshot = getStore();
|
|
this._stdio = ['inherit', 'pipe', 'pipe'];
|
|
this._nothrow = false;
|
|
this._quiet = false;
|
|
this._resolved = false;
|
|
this._halted = false;
|
|
this._piped = false;
|
|
this._prerun = noop;
|
|
this._postrun = noop;
|
|
}
|
|
_bind(cmd, from, resolve, reject, options) {
|
|
this._command = cmd;
|
|
this._from = from;
|
|
this._resolve = resolve;
|
|
this._reject = reject;
|
|
this._snapshot = { ...options };
|
|
}
|
|
run() {
|
|
const $ = this._snapshot;
|
|
if (this.child)
|
|
return this; // The _run() can be called from a few places.
|
|
this._prerun(); // In case $1.pipe($2), the $2 returned, and on $2._run() invoke $1._run().
|
|
$.log({
|
|
kind: 'cmd',
|
|
cmd: this._command,
|
|
verbose: $.verbose && !this._quiet,
|
|
});
|
|
this.child = $.spawn($.prefix + this._command, {
|
|
cwd: $.cwd ?? $[processCwd],
|
|
shell: typeof $.shell === 'string' ? $.shell : true,
|
|
stdio: this._stdio,
|
|
windowsHide: true,
|
|
env: $.env,
|
|
});
|
|
this.child.on('close', (code, signal) => {
|
|
let message = `exit code: ${code}`;
|
|
if (code != 0 || signal != null) {
|
|
message = `${stderr || '\n'} at ${this._from}`;
|
|
message += `\n exit code: ${code}${exitCodeInfo(code) ? ' (' + exitCodeInfo(code) + ')' : ''}`;
|
|
if (signal != null) {
|
|
message += `\n signal: ${signal}`;
|
|
}
|
|
}
|
|
let output = new ProcessOutput(code, signal, stdout, stderr, combined, message);
|
|
if (code === 0 || this._nothrow) {
|
|
this._resolve(output);
|
|
}
|
|
else {
|
|
this._reject(output);
|
|
}
|
|
this._resolved = true;
|
|
});
|
|
this.child.on('error', (err) => {
|
|
const message = `${err.message}\n` +
|
|
` errno: ${err.errno} (${errnoMessage(err.errno)})\n` +
|
|
` code: ${err.code}\n` +
|
|
` at ${this._from}`;
|
|
this._reject(new ProcessOutput(null, null, stdout, stderr, combined, message));
|
|
this._resolved = true;
|
|
});
|
|
let stdout = '', stderr = '', combined = '';
|
|
let onStdout = (data) => {
|
|
$.log({ kind: 'stdout', data, verbose: $.verbose && !this._quiet });
|
|
stdout += data;
|
|
combined += data;
|
|
};
|
|
let onStderr = (data) => {
|
|
$.log({ kind: 'stderr', data, verbose: $.verbose && !this._quiet });
|
|
stderr += data;
|
|
combined += data;
|
|
};
|
|
if (!this._piped)
|
|
this.child.stdout?.on('data', onStdout); // If process is piped, don't collect or print output.
|
|
this.child.stderr?.on('data', onStderr); // Stderr should be printed regardless of piping.
|
|
this._postrun(); // In case $1.pipe($2), after both subprocesses are running, we can pipe $1.stdout to $2.stdin.
|
|
if (this._timeout && this._timeoutSignal) {
|
|
const t = setTimeout(() => this.kill(this._timeoutSignal), this._timeout);
|
|
this.finally(() => clearTimeout(t)).catch(noop);
|
|
}
|
|
return this;
|
|
}
|
|
get stdin() {
|
|
this.stdio('pipe');
|
|
this.run();
|
|
assert(this.child);
|
|
if (this.child.stdin == null)
|
|
throw new Error('The stdin of subprocess is null.');
|
|
return this.child.stdin;
|
|
}
|
|
get stdout() {
|
|
this.run();
|
|
assert(this.child);
|
|
if (this.child.stdout == null)
|
|
throw new Error('The stdout of subprocess is null.');
|
|
return this.child.stdout;
|
|
}
|
|
get stderr() {
|
|
this.run();
|
|
assert(this.child);
|
|
if (this.child.stderr == null)
|
|
throw new Error('The stderr of subprocess is null.');
|
|
return this.child.stderr;
|
|
}
|
|
get exitCode() {
|
|
return this.then((p) => p.exitCode, (p) => p.exitCode);
|
|
}
|
|
then(onfulfilled, onrejected) {
|
|
if (this.isHalted && !this.child) {
|
|
throw new Error('The process is halted!');
|
|
}
|
|
return super.then(onfulfilled, onrejected);
|
|
}
|
|
catch(onrejected) {
|
|
return super.catch(onrejected);
|
|
}
|
|
pipe(dest) {
|
|
if (typeof dest == 'string')
|
|
throw new Error('The pipe() method does not take strings. Forgot $?');
|
|
if (this._resolved) {
|
|
if (dest instanceof ProcessPromise)
|
|
dest.stdin.end(); // In case of piped stdin, we may want to close stdin of dest as well.
|
|
throw new Error("The pipe() method shouldn't be called after promise is already resolved!");
|
|
}
|
|
this._piped = true;
|
|
if (dest instanceof ProcessPromise) {
|
|
dest.stdio('pipe');
|
|
dest._prerun = this.run.bind(this);
|
|
dest._postrun = () => {
|
|
if (!dest.child)
|
|
throw new Error('Access to stdin of pipe destination without creation a subprocess.');
|
|
this.stdout.pipe(dest.stdin);
|
|
};
|
|
return dest;
|
|
}
|
|
else {
|
|
this._postrun = () => this.stdout.pipe(dest);
|
|
return this;
|
|
}
|
|
}
|
|
async kill(signal = 'SIGTERM') {
|
|
if (!this.child)
|
|
throw new Error('Trying to kill a process without creating one.');
|
|
if (!this.child.pid)
|
|
throw new Error('The process pid is undefined.');
|
|
let children = await psTree(this.child.pid);
|
|
for (const p of children) {
|
|
try {
|
|
process.kill(+p.PID, signal);
|
|
}
|
|
catch (e) { }
|
|
}
|
|
try {
|
|
process.kill(this.child.pid, signal);
|
|
}
|
|
catch (e) { }
|
|
}
|
|
stdio(stdin, stdout = 'pipe', stderr = 'pipe') {
|
|
this._stdio = [stdin, stdout, stderr];
|
|
return this;
|
|
}
|
|
nothrow() {
|
|
this._nothrow = true;
|
|
return this;
|
|
}
|
|
quiet() {
|
|
this._quiet = true;
|
|
return this;
|
|
}
|
|
timeout(d, signal = 'SIGTERM') {
|
|
this._timeout = parseDuration(d);
|
|
this._timeoutSignal = signal;
|
|
return this;
|
|
}
|
|
halt() {
|
|
this._halted = true;
|
|
return this;
|
|
}
|
|
get isHalted() {
|
|
return this._halted;
|
|
}
|
|
}
|
|
export class ProcessOutput extends Error {
|
|
constructor(code, signal, stdout, stderr, combined, message) {
|
|
super(message);
|
|
this._code = code;
|
|
this._signal = signal;
|
|
this._stdout = stdout;
|
|
this._stderr = stderr;
|
|
this._combined = combined;
|
|
}
|
|
toString() {
|
|
return this._combined;
|
|
}
|
|
get stdout() {
|
|
return this._stdout;
|
|
}
|
|
get stderr() {
|
|
return this._stderr;
|
|
}
|
|
get exitCode() {
|
|
return this._code;
|
|
}
|
|
get signal() {
|
|
return this._signal;
|
|
}
|
|
[inspect.custom]() {
|
|
let stringify = (s, c) => s.length === 0 ? "''" : c(inspect(s));
|
|
return `ProcessOutput {
|
|
stdout: ${stringify(this.stdout, chalk.green)},
|
|
stderr: ${stringify(this.stderr, chalk.red)},
|
|
signal: ${inspect(this.signal)},
|
|
exitCode: ${(this.exitCode === 0 ? chalk.green : chalk.red)(this.exitCode)}${exitCodeInfo(this.exitCode)
|
|
? chalk.grey(' (' + exitCodeInfo(this.exitCode) + ')')
|
|
: ''}
|
|
}`;
|
|
}
|
|
}
|
|
export function within(callback) {
|
|
return storage.run({ ...getStore() }, callback);
|
|
}
|
|
function syncCwd() {
|
|
if ($[processCwd] != process.cwd())
|
|
process.chdir($[processCwd]);
|
|
}
|
|
export function cd(dir) {
|
|
$.log({ kind: 'cd', dir });
|
|
process.chdir(dir);
|
|
$[processCwd] = process.cwd();
|
|
}
|
|
export function log(entry) {
|
|
switch (entry.kind) {
|
|
case 'cmd':
|
|
if (!entry.verbose)
|
|
return;
|
|
process.stderr.write(formatCmd(entry.cmd));
|
|
break;
|
|
case 'stdout':
|
|
case 'stderr':
|
|
if (!entry.verbose)
|
|
return;
|
|
process.stderr.write(entry.data);
|
|
break;
|
|
case 'cd':
|
|
if (!$.verbose)
|
|
return;
|
|
process.stderr.write('$ ' + chalk.greenBright('cd') + ` ${entry.dir}\n`);
|
|
break;
|
|
case 'fetch':
|
|
if (!$.verbose)
|
|
return;
|
|
const init = entry.init ? ' ' + inspect(entry.init) : '';
|
|
process.stderr.write('$ ' + chalk.greenBright('fetch') + ` ${entry.url}${init}\n`);
|
|
break;
|
|
case 'retry':
|
|
if (!$.verbose)
|
|
return;
|
|
process.stderr.write(entry.error + '\n');
|
|
}
|
|
}
|