Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
601 lines
18 KiB
JavaScript
601 lines
18 KiB
JavaScript
/*
|
|
* Licensed to the Apache Software Foundation (ASF) under one
|
|
* or more contributor license agreements. See the NOTICE file
|
|
* distributed with this work for additional information
|
|
* regarding copyright ownership. The ASF licenses this file
|
|
* to you 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
|
|
*
|
|
* http://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 { spawnSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import {
|
|
cpSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
rmSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { createServer } from 'node:net';
|
|
import { tmpdir, userInfo } from 'node:os';
|
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { npmSpawnOptions } from './npm-spawn.mjs';
|
|
|
|
const PROCESS_TIMEOUT_MS = 90_000;
|
|
const MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
const MAX_TARBALL_BYTES = 64 * 1024 * 1024;
|
|
const fixturePath = fileURLToPath(
|
|
new URL('./released-cli-state-root-fixture.mjs', import.meta.url),
|
|
);
|
|
const qualificationPath = fileURLToPath(import.meta.url);
|
|
const SUDO_BWRAP_ENV = 'MAKA_QUALIFICATION_BWRAP_USE_SUDO';
|
|
|
|
export function parseQualificationArgs(argv) {
|
|
const allowedNames = new Set([
|
|
'--source',
|
|
'--target',
|
|
'--source-sha256',
|
|
'--target-sha256',
|
|
'--expect-epoch-relation',
|
|
]);
|
|
const values = new Map();
|
|
for (let index = 0; index < argv.length; index += 2) {
|
|
const name = argv[index];
|
|
const value = argv[index + 1];
|
|
if (!name?.startsWith('--') || value === undefined) {
|
|
throw new Error('Qualification arguments must be --name value pairs');
|
|
}
|
|
if (!allowedNames.has(name)) throw new Error(`Unknown qualification argument: ${name}`);
|
|
if (values.has(name)) throw new Error(`Duplicate qualification argument: ${name}`);
|
|
values.set(name, value);
|
|
}
|
|
const source = requireAbsolutePath(values, '--source');
|
|
const target = requireAbsolutePath(values, '--target');
|
|
const sourceSha256 = requireSha256(values, '--source-sha256');
|
|
const targetSha256 = requireSha256(values, '--target-sha256');
|
|
const expectedEpochRelation = values.get('--expect-epoch-relation') ?? 'any';
|
|
if (!['same', 'different', 'any'].includes(expectedEpochRelation)) {
|
|
throw new Error('Expected epoch relation must be same, different, or any');
|
|
}
|
|
return { source, target, sourceSha256, targetSha256, expectedEpochRelation };
|
|
}
|
|
|
|
export function assertExpectedEpochRelation(sourceEpoch, targetEpoch, expected) {
|
|
if (!Number.isSafeInteger(sourceEpoch) || !Number.isSafeInteger(targetEpoch)) {
|
|
throw new Error('Release compatibility epochs must be safe integers');
|
|
}
|
|
const actual = sourceEpoch === targetEpoch ? 'same' : 'different';
|
|
if (expected !== 'any' && expected !== actual) {
|
|
throw new Error(`Expected ${expected} compatibility epochs, found ${actual}`);
|
|
}
|
|
return actual;
|
|
}
|
|
|
|
export function sha256File(path) {
|
|
return createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
}
|
|
|
|
export function qualificationSandboxInvocation({ args, account, useSudo }) {
|
|
if (!useSudo) return { command: 'bwrap', args };
|
|
const separator = args.indexOf('--');
|
|
if (separator === -1) throw new Error('Qualification sandbox command is missing');
|
|
return {
|
|
command: 'sudo',
|
|
args: [
|
|
'--non-interactive',
|
|
'--',
|
|
'/usr/bin/bwrap',
|
|
...args.slice(0, separator),
|
|
'--cap-add',
|
|
'CAP_SETUID',
|
|
'--cap-add',
|
|
'CAP_SETGID',
|
|
'--cap-add',
|
|
'CAP_SETPCAP',
|
|
'--',
|
|
'/usr/bin/setpriv',
|
|
'--regid',
|
|
String(account.gid),
|
|
'--reuid',
|
|
String(account.uid),
|
|
'--clear-groups',
|
|
'--inh-caps=-all',
|
|
'--ambient-caps=-all',
|
|
'--bounding-set=-all',
|
|
'--no-new-privs',
|
|
...args.slice(separator + 1),
|
|
],
|
|
};
|
|
}
|
|
|
|
export async function qualifyReleasedCliStateRoot(input) {
|
|
if (process.platform !== 'linux') {
|
|
throw new Error('Released State Root qualification currently requires Linux');
|
|
}
|
|
assertCommandAvailable('bwrap');
|
|
assertCommandAvailable('timeout');
|
|
const useSudo = parseSudoBwrapEnvironment(process.env[SUDO_BWRAP_ENV]);
|
|
if (useSudo) {
|
|
assertCommandAvailable('sudo');
|
|
assertCommandAvailable('/usr/bin/setpriv');
|
|
}
|
|
assertTarballDigest(input.source, input.sourceSha256, 'source');
|
|
assertTarballDigest(input.target, input.targetSha256, 'target');
|
|
const scope = mkdtempSync(join(tmpdir(), 'maka-released-state-root-'));
|
|
try {
|
|
const sandbox = prepareSandbox(scope);
|
|
const source = installArtifact({
|
|
role: 'source',
|
|
tarball: input.source,
|
|
scope,
|
|
sandbox,
|
|
});
|
|
const target = installArtifact({
|
|
role: 'target',
|
|
tarball: input.target,
|
|
scope,
|
|
sandbox,
|
|
});
|
|
const epochRelation = assertExpectedEpochRelation(
|
|
source.compatibilityEpoch,
|
|
target.compatibilityEpoch,
|
|
input.expectedEpochRelation,
|
|
);
|
|
const innerInputPath = join(scope, 'qualification-input.json');
|
|
writeFileSync(
|
|
innerInputPath,
|
|
`${JSON.stringify({
|
|
scope,
|
|
source,
|
|
target,
|
|
sourceSha256: input.sourceSha256,
|
|
targetSha256: input.targetSha256,
|
|
epochRelation,
|
|
})}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
return runQualificationSandbox({ innerInputPath, sandbox, scope, useSudo });
|
|
} finally {
|
|
rmSync(scope, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
}
|
|
}
|
|
|
|
async function qualifyInstalledArtifacts(input) {
|
|
const { scope, source, target } = input;
|
|
const rootPath = join(scope, 'state-root');
|
|
const goldenPath = join(scope, 'golden-root');
|
|
mkdirSync(rootPath, { recursive: true });
|
|
const seeded = runFixture({ action: 'seed', artifact: source, rootPath, scope });
|
|
assertFacts(seeded);
|
|
cpSync(rootPath, goldenPath, { recursive: true, force: true });
|
|
|
|
const sourceReady = await runInstalledRuntimeHost({ artifact: source, rootPath, scope });
|
|
const sourceFacts = runFixture({ action: 'inspect', artifact: source, rootPath, scope });
|
|
assertSameFacts(seeded, sourceFacts, 'source self-reopen');
|
|
|
|
restoreGolden(rootPath, goldenPath);
|
|
const writerFence = await proveWriterFence({ source, target, rootPath, scope });
|
|
|
|
restoreGolden(rootPath, goldenPath);
|
|
const targetReady = await runInstalledRuntimeHost({ artifact: target, rootPath, scope });
|
|
const targetFacts = runFixture({ action: 'inspect', artifact: target, rootPath, scope });
|
|
assertSameFacts(seeded, targetFacts, 'target transition');
|
|
|
|
return {
|
|
schemaVersion: 1,
|
|
source: artifactEvidence(source, input.sourceSha256),
|
|
target: artifactEvidence(target, input.targetSha256),
|
|
epochRelation: input.epochRelation,
|
|
facts: seeded,
|
|
checks: {
|
|
sourceSelfReopen: { kind: 'passed', host: sourceReady },
|
|
concurrentWriterFence: writerFence,
|
|
targetTransition: { kind: 'passed', host: targetReady },
|
|
},
|
|
claims: {
|
|
rollback: 'not_claimed',
|
|
downgrade: 'unsupported',
|
|
externalNpmReconciliation: 'not_qualified_by_this_harness',
|
|
},
|
|
};
|
|
}
|
|
|
|
function requireAbsolutePath(values, name) {
|
|
const value = values.get(name);
|
|
if (!value || !isAbsolute(value)) throw new Error(`${name} must be an absolute path`);
|
|
return resolve(value);
|
|
}
|
|
|
|
function requireSha256(values, name) {
|
|
const value = values.get(name);
|
|
if (!value || !/^[a-f0-9]{64}$/u.test(value)) {
|
|
throw new Error(`${name} must be a lowercase SHA-256`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function assertTarballDigest(path, expected, role) {
|
|
const stat = statSync(path);
|
|
if (!stat.isFile() || stat.size > MAX_TARBALL_BYTES) {
|
|
throw new Error(`The ${role} release tarball exceeds the qualification boundary`);
|
|
}
|
|
const actual = sha256File(path);
|
|
if (actual !== expected) {
|
|
throw new Error(`The ${role} release tarball SHA-256 does not match`);
|
|
}
|
|
}
|
|
|
|
function assertCommandAvailable(command) {
|
|
const result = spawnSync(command, ['--version'], { encoding: 'utf8' });
|
|
if (result.error?.code === 'ENOENT') throw new Error(`${command} is required`);
|
|
if (result.status !== 0) throw new Error(`${command} is unavailable`);
|
|
}
|
|
|
|
function prepareSandbox(scope) {
|
|
const account = userInfo();
|
|
const home = join(scope, 'home');
|
|
const temp = join(scope, 'tmp');
|
|
const etc = join(scope, 'etc');
|
|
for (const path of [home, temp, etc]) mkdirSync(path, { recursive: true });
|
|
const passwd = join(etc, 'passwd');
|
|
const group = join(etc, 'group');
|
|
writeFileSync(
|
|
passwd,
|
|
`maka-qualification:x:${account.uid}:${account.gid}:Maka qualification:${home}:/bin/sh\n`,
|
|
);
|
|
writeFileSync(group, `maka-qualification:x:${account.gid}:\n`);
|
|
return {
|
|
account,
|
|
home,
|
|
temp,
|
|
passwd,
|
|
group,
|
|
environment: {
|
|
...process.env,
|
|
HOME: home,
|
|
XDG_CACHE_HOME: join(home, '.cache'),
|
|
XDG_CONFIG_HOME: join(home, '.config'),
|
|
XDG_DATA_HOME: join(home, '.local/share'),
|
|
TMPDIR: temp,
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseSudoBwrapEnvironment(value) {
|
|
if (value === undefined || value === '') return false;
|
|
if (value === '1') return true;
|
|
throw new Error(`${SUDO_BWRAP_ENV} must be 1 when set`);
|
|
}
|
|
|
|
function installArtifact({ role, tarball, scope, sandbox }) {
|
|
const prefix = join(scope, `${role}-prefix`);
|
|
const cache = join(scope, `${role}-npm-cache`);
|
|
const result = spawnSync(
|
|
'npm',
|
|
[
|
|
'install',
|
|
'--global',
|
|
'--prefix',
|
|
prefix,
|
|
'--cache',
|
|
cache,
|
|
'--offline',
|
|
'--no-audit',
|
|
'--no-fund',
|
|
tarball,
|
|
],
|
|
npmSpawnOptions({
|
|
cwd: scope,
|
|
env: {
|
|
...sandbox.environment,
|
|
npm_config_registry: 'http://127.0.0.1:9/',
|
|
},
|
|
encoding: 'utf8',
|
|
}),
|
|
);
|
|
if (result.status !== 0) {
|
|
throw new Error(`Unable to install the ${role} release tarball: ${result.stderr}`);
|
|
}
|
|
const packageRoot = join(prefix, 'lib/node_modules/maka-agent');
|
|
const manifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
|
|
const protocolPath = join(packageRoot, 'node_modules/@maka/runtime-host/dist/protocol/index.js');
|
|
const protocol = readFileSync(protocolPath, 'utf8');
|
|
const epoch = protocol.match(/RUNTIME_HOST_COMPATIBILITY_EPOCH\s*=\s*(\d+)/u)?.[1];
|
|
if (!epoch) throw new Error(`The ${role} release has no compatibility epoch`);
|
|
const cliPath = join(packageRoot, 'dist/cli.js');
|
|
const versionResult = spawnSync(process.execPath, [cliPath, '--version'], {
|
|
cwd: scope,
|
|
env: sandbox.environment,
|
|
encoding: 'utf8',
|
|
maxBuffer: MAX_OUTPUT_BYTES,
|
|
timeout: PROCESS_TIMEOUT_MS,
|
|
});
|
|
if (versionResult.status !== 0) {
|
|
throw new Error(`The ${role} CLI version check failed: ${versionResult.stderr}`);
|
|
}
|
|
const version = versionResult.stdout.trim();
|
|
if (version !== manifest.version) {
|
|
throw new Error(`The ${role} CLI reports ${version}; expected ${manifest.version}`);
|
|
}
|
|
return { role, prefix, packageRoot, cliPath, version, compatibilityEpoch: Number(epoch) };
|
|
}
|
|
|
|
export function qualificationSandboxArgs({ innerInputPath, sandbox, scope }) {
|
|
return [
|
|
'--die-with-parent',
|
|
'--ro-bind',
|
|
'/',
|
|
'/',
|
|
'--tmpfs',
|
|
'/tmp',
|
|
'--chmod',
|
|
'1777',
|
|
'/tmp',
|
|
'--bind',
|
|
scope,
|
|
scope,
|
|
'--ro-bind',
|
|
sandbox.passwd,
|
|
'/etc/passwd',
|
|
'--ro-bind',
|
|
sandbox.group,
|
|
'/etc/group',
|
|
'--setenv',
|
|
'HOME',
|
|
sandbox.home,
|
|
'--setenv',
|
|
'XDG_CACHE_HOME',
|
|
sandbox.environment.XDG_CACHE_HOME,
|
|
'--setenv',
|
|
'XDG_CONFIG_HOME',
|
|
sandbox.environment.XDG_CONFIG_HOME,
|
|
'--setenv',
|
|
'XDG_DATA_HOME',
|
|
sandbox.environment.XDG_DATA_HOME,
|
|
'--setenv',
|
|
'TMPDIR',
|
|
sandbox.temp,
|
|
'--chdir',
|
|
scope,
|
|
'--',
|
|
process.execPath,
|
|
qualificationPath,
|
|
'--inner',
|
|
innerInputPath,
|
|
];
|
|
}
|
|
|
|
function runQualificationSandbox({ innerInputPath, sandbox, scope, useSudo }) {
|
|
const invocation = qualificationSandboxInvocation({
|
|
args: qualificationSandboxArgs({ innerInputPath, sandbox, scope }),
|
|
account: sandbox.account,
|
|
useSudo,
|
|
});
|
|
const result = spawnSync(invocation.command, invocation.args, {
|
|
cwd: scope,
|
|
env: sandbox.environment,
|
|
encoding: 'utf8',
|
|
maxBuffer: MAX_OUTPUT_BYTES,
|
|
timeout: PROCESS_TIMEOUT_MS * 4,
|
|
});
|
|
if (result.status !== 0) {
|
|
throw new Error(`Qualification sandbox failed: ${result.stderr || result.stdout}`);
|
|
}
|
|
return parseLastJsonLine(result.stdout);
|
|
}
|
|
|
|
function runFixture({ action, artifact, rootPath, scope }) {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[fixturePath, '--action', action, '--package-root', artifact.packageRoot, '--root', rootPath],
|
|
{
|
|
cwd: scope,
|
|
env: process.env,
|
|
encoding: 'utf8',
|
|
maxBuffer: MAX_OUTPUT_BYTES,
|
|
timeout: PROCESS_TIMEOUT_MS,
|
|
},
|
|
);
|
|
if (result.status !== 0) {
|
|
throw new Error(`Released fixture failed: ${result.stderr || result.stdout}`);
|
|
}
|
|
return parseLastJsonLine(result.stdout);
|
|
}
|
|
|
|
async function proveWriterFence({ source, target, rootPath, scope }) {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[
|
|
fixturePath,
|
|
'--action',
|
|
'fence',
|
|
'--package-root',
|
|
source.packageRoot,
|
|
'--root',
|
|
rootPath,
|
|
'--target-package-root',
|
|
target.packageRoot,
|
|
],
|
|
{
|
|
cwd: scope,
|
|
env: process.env,
|
|
encoding: 'utf8',
|
|
maxBuffer: MAX_OUTPUT_BYTES,
|
|
timeout: PROCESS_TIMEOUT_MS,
|
|
},
|
|
);
|
|
if (result.status !== 0) {
|
|
throw new Error(`Released writer-fence fixture failed: ${result.stderr || result.stdout}`);
|
|
}
|
|
const attempted = parseLastJsonLine(result.stdout);
|
|
if (attempted.kind !== 'writer_fenced') {
|
|
throw new Error('The target release acquired a concurrently held State Root writer');
|
|
}
|
|
return { kind: 'passed', rootId: attempted.rootId };
|
|
}
|
|
|
|
async function runInstalledRuntimeHost({ artifact, rootPath, scope }) {
|
|
const configPath = join(scope, `${artifact.role}-runtime-host-service.json`);
|
|
writeFileSync(
|
|
configPath,
|
|
`${JSON.stringify({
|
|
schemaVersion: 2,
|
|
rootPath,
|
|
projectDirectoryRoots: [{ label: 'qualification', path: scope }],
|
|
websocket: {
|
|
host: '127.0.0.1',
|
|
port: await allocateLoopbackPort(),
|
|
path: '/runtime-host',
|
|
},
|
|
launch: { nodePath: process.execPath, cliPath: artifact.cliPath },
|
|
})}\n`,
|
|
{ mode: 0o600 },
|
|
);
|
|
const result = spawnSync(
|
|
'timeout',
|
|
[
|
|
'--signal=INT',
|
|
'--kill-after=15s',
|
|
'--preserve-status',
|
|
'10s',
|
|
process.execPath,
|
|
artifact.cliPath,
|
|
'runtime-host',
|
|
'serve',
|
|
'--managed-service-config',
|
|
configPath,
|
|
'--json',
|
|
],
|
|
{
|
|
cwd: scope,
|
|
env: process.env,
|
|
encoding: 'utf8',
|
|
maxBuffer: MAX_OUTPUT_BYTES,
|
|
timeout: PROCESS_TIMEOUT_MS,
|
|
},
|
|
);
|
|
if (result.status !== 0) {
|
|
throw new Error(`Released Runtime Host failed: ${result.stderr || result.stdout}`);
|
|
}
|
|
const ready = findJsonLine(result.stdout, (value) => value.event === 'runtime_host_ready');
|
|
if (!ready?.rootId || !ready.hostEpoch) {
|
|
throw new Error(`The released Runtime Host did not publish Ready: ${result.stderr}`);
|
|
}
|
|
return { rootId: ready.rootId, hostEpoch: ready.hostEpoch };
|
|
}
|
|
|
|
function parseLastJsonLine(output) {
|
|
const lines = output.trim().split(/\r?\n/u);
|
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
try {
|
|
return JSON.parse(lines[index]);
|
|
} catch {
|
|
// Earlier lines may contain runtime diagnostics.
|
|
}
|
|
}
|
|
throw new Error('Released fixture produced no JSON evidence');
|
|
}
|
|
|
|
function findJsonLine(output, predicate) {
|
|
for (const line of output.split(/\r?\n/u)) {
|
|
if (!line.trim().startsWith('{')) continue;
|
|
try {
|
|
const value = JSON.parse(line);
|
|
if (predicate(value)) return value;
|
|
} catch {
|
|
// Non-JSON diagnostics remain outside the evidence channel.
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function assertFacts(value) {
|
|
if (
|
|
value.kind !== 'facts' ||
|
|
!value.rootId ||
|
|
!value.session?.id ||
|
|
!value.session?.message?.id ||
|
|
!value.scheduledTask?.id
|
|
) {
|
|
throw new Error('Released fixture evidence is incomplete');
|
|
}
|
|
}
|
|
|
|
function assertSameFacts(expected, actual, stage) {
|
|
assertFacts(actual);
|
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
throw new Error(`Durable facts changed during ${stage}`);
|
|
}
|
|
}
|
|
|
|
function restoreGolden(rootPath, goldenPath) {
|
|
for (const entry of readdirSync(rootPath)) {
|
|
rmSync(join(rootPath, entry), { recursive: true, force: true });
|
|
}
|
|
for (const entry of readdirSync(goldenPath)) {
|
|
cpSync(join(goldenPath, entry), join(rootPath, entry), {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
function artifactEvidence(artifact, sha256) {
|
|
return {
|
|
version: artifact.version,
|
|
compatibilityEpoch: artifact.compatibilityEpoch,
|
|
sha256,
|
|
};
|
|
}
|
|
|
|
async function allocateLoopbackPort() {
|
|
const server = createServer();
|
|
await new Promise((resolvePromise, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', resolvePromise);
|
|
});
|
|
const address = server.address();
|
|
await new Promise((resolvePromise, reject) =>
|
|
server.close((error) => (error ? reject(error) : resolvePromise())),
|
|
);
|
|
if (!address || typeof address === 'string') throw new Error('Unable to allocate a port');
|
|
return address.port;
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
try {
|
|
if (process.argv[2] === '--inner') {
|
|
const innerInputPath = process.argv[3];
|
|
if (!innerInputPath || !isAbsolute(innerInputPath) || process.argv.length !== 4) {
|
|
throw new Error('Qualification sandbox requires one absolute input path');
|
|
}
|
|
const report = await qualifyInstalledArtifacts(
|
|
JSON.parse(readFileSync(innerInputPath, 'utf8')),
|
|
);
|
|
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
} else {
|
|
const report = await qualifyReleasedCliStateRoot(
|
|
parseQualificationArgs(process.argv.slice(2)),
|
|
);
|
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
}
|
|
} catch (error) {
|
|
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|