Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

quickshell/nuguland/noko.mjs

Raw
#!/usr/bin/env node
import { createRequire } from 'node:module';
import { appendFileSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
import { access, constants } from 'node:fs/promises';
import { spawnSync } from 'node:child_process';
import { Readable } from 'node:stream';
import { discoverParakeetDir, loadSentencePiece, ParakeetOnnx } from './noko-parakeet.mjs';
import { loadOnnxRuntime, OnnxVad, vadProbabilities, vadRuntimeReport } from './noko-vad.mjs';

const require = createRequire(import.meta.url);
const Noko = require('./noko.js');

function usage() {
  return `usage:
  node noko.mjs doctor
  node noko.mjs system-deps-json
  node noko.mjs assets-json
  node noko.mjs check-assets-json
  node noko.mjs install-assets
  node noko.mjs update-assets
  node noko.mjs install-assets-progress
  node noko.mjs update-assets-progress
  node noko.mjs capture-json seconds output.json [device]
  node noko.mjs vad-json input.json output.json
  node noko.mjs vad-gate-json input.json output.json
  node noko.mjs transcribe-real-json input.json history.jsonl
  node noko.mjs transcribe-raw-json input.f32 history.jsonl
  node noko.mjs transcribe-capture-json seconds history.jsonl [device]
  node noko.mjs transcribe-json input.json history.jsonl

env:
  NOKO_DATA_DIR       Noko-owned asset root, defaults to XDG data dir
  NOKO_VAD_ONNX       override path to Silero/equivalent VAD ONNX
  NOKO_PARAKEET_DIR   override Parakeet model directory
  NOKO_NO_SYSTEM_INSTALL=1 skips package-manager install during setup
`;
}

function commandExists(name) {
  const result = spawnSync('sh', ['-c', `command -v ${JSON.stringify(name)} >/dev/null 2>&1`]);
  return result.status === 0;
}

const SYSTEM_DEPENDENCIES = [
  {
    command: 'pw-record',
    label: 'mic capture',
    required: true,
    packages: {
      arch: 'pipewire',
      fedora: 'pipewire-utils',
      debian: 'pipewire-bin'
    }
  },
  {
    command: 'wtype',
    label: 'text insertion',
    required: true,
    packages: {
      arch: 'wtype',
      fedora: 'wtype',
      debian: 'wtype'
    }
  }
];

function systemDependencyReport() {
  return SYSTEM_DEPENDENCIES.map(dep => Object.assign({}, dep, {
    exists: commandExists(dep.command)
  }));
}

function missingSystemDependencies(report = systemDependencyReport()) {
  return report.filter(dep => dep.required && !dep.exists).map(dep => dep.command);
}

function osIdLike() {
  try {
    const text = readFileSync('/etc/os-release', 'utf8');
    const ids = [];
    for (const key of ['ID', 'ID_LIKE']) {
      const match = text.match(new RegExp(`^${key}=(.*)$`, 'm'));
      if (match) ids.push(...match[1].replace(/^"|"$/g, '').split(/\s+/));
    }
    return ids;
  } catch (_error) {
    return [];
  }
}

function packageFamily() {
  const ids = osIdLike();
  if (ids.includes('arch') || commandExists('pacman')) return 'arch';
  if (ids.includes('fedora') || ids.includes('rhel') || commandExists('dnf')) return 'fedora';
  if (ids.includes('debian') || ids.includes('ubuntu') || commandExists('apt-get')) return 'debian';
  return '';
}

function unique(values) {
  return Array.from(new Set(values.filter(Boolean)));
}

function systemInstallCommand(report = systemDependencyReport()) {
  const missing = report.filter(dep => dep.required && !dep.exists);
  if (missing.length === 0) return [];
  const family = packageFamily();
  const packages = unique(missing.map(dep => dep.packages && dep.packages[family]));
  if (packages.length === 0) return [];
  if (family === 'arch') return ['pkexec', 'pacman', '-S', '--needed', '--noconfirm', ...packages];
  if (family === 'fedora') return ['pkexec', 'dnf', 'install', '-y', ...packages];
  if (family === 'debian') return ['pkexec', 'apt-get', 'install', '-y', ...packages];
  return [];
}

function systemDependencyStatus() {
  const dependencies = systemDependencyReport();
  return {
    dependencies,
    missing: missingSystemDependencies(dependencies),
    installCommand: systemInstallCommand(dependencies),
    packageFamily: packageFamily(),
    canInstall: systemInstallCommand(dependencies).length > 0 && commandExists('pkexec')
  };
}

function installSystemDependencies(options = {}) {
  const status = systemDependencyStatus();
  emitProgress(options.emit, { event: 'system-deps-start', label: 'system tools', progress: 0.01, missing: status.missing });
  if (status.missing.length === 0 || process.env.NOKO_NO_SYSTEM_INSTALL === '1') {
    emitProgress(options.emit, { event: 'system-deps-done', label: 'system tools', progress: 0.08, skipped: true, missing: status.missing });
    return Object.assign({ skipped: true }, status);
  }
  if (!status.canInstall) throw new Error(`missing system tools: ${status.missing.join(', ')}; no supported pkexec install command`);
  const result = spawnSync(status.installCommand[0], status.installCommand.slice(1), {
    encoding: 'utf8',
    maxBuffer: 1024 * 1024 * 4
  });
  if (result.status !== 0) throw new Error(`system tool install failed: ${(result.stderr || result.stdout || '').trim()}`);
  const after = systemDependencyStatus();
  if (after.missing.length > 0) throw new Error(`system tools still missing: ${after.missing.join(', ')}`);
  emitProgress(options.emit, { event: 'system-deps-done', label: 'system tools', progress: 0.08, skipped: false });
  return Object.assign({ skipped: false }, after);
}

function dataDir() {
  if (process.env.NOKO_DATA_DIR) return process.env.NOKO_DATA_DIR;
  const root = process.env.XDG_DATA_HOME || `${process.env.HOME || '.'}/.local/share`;
  return `${root}/nuguland/noko`;
}

const PARAKEET_REPO = process.env.NOKO_PARAKEET_REPO || 'https://huggingface.co/smcleod/parakeet-tdt-0.6b-v3-int8/resolve/main';
const ASSET_FILES = {
  vad: {
    file: 'silero_vad.onnx',
    url: process.env.NOKO_VAD_URL || 'https://github.com/snakers4/silero-vad/raw/master/src/silero_vad/data/silero_vad.onnx'
  },
  parakeet: {
    dir: 'parakeet-tdt-0.6b-v3-int8',
    files: {
      config: { file: 'config.json', url: `${PARAKEET_REPO}/config.json` },
      preprocessor: { file: 'nemo128.onnx', url: `${PARAKEET_REPO}/nemo128.onnx` },
      encoder: { file: 'encoder-model.int8.onnx', url: `${PARAKEET_REPO}/encoder-model.int8.onnx` },
      decoderJoint: { file: 'decoder_joint-model.int8.onnx', url: `${PARAKEET_REPO}/decoder_joint-model.int8.onnx` },
      vocab: { file: 'vocab.txt', url: `${PARAKEET_REPO}/vocab.txt` }
    }
  }
};

function defaultVadPath() {
  return `${dataDir()}/vad/${ASSET_FILES.vad.file}`;
}

function defaultParakeetDir() {
  return `${dataDir()}/${ASSET_FILES.parakeet.dir}`;
}

function runtimeDir() {
  return `${dataDir()}/runtime`;
}

function runtimeModulePath() {
  return `${runtimeDir()}/node_modules/onnxruntime-node`;
}

function resolvedVadPath() {
  return process.env.NOKO_VAD_ONNX || defaultVadPath();
}

function resolvedParakeetDir() {
  return process.env.NOKO_PARAKEET_DIR || defaultParakeetDir();
}

function fileStatus(path) {
  try {
    const stat = statSync(path);
    return { path, exists: true, size: stat.size };
  } catch (_error) {
    return { path, exists: false, size: 0 };
  }
}

function assetFiles(report = null) {
  const current = report || assetReport();
  return [{ key: 'vad', url: current.vad.url, path: current.vad.path }]
    .concat(Object.entries(current.parakeet.files).map(([key, value]) => Object.assign({ key }, value)));
}

function assetReport() {
  const root = dataDir();
  const parakeetPath = resolvedParakeetDir();
  const parakeetFiles = Object.fromEntries(Object.entries(ASSET_FILES.parakeet.files).map(([key, value]) => [key, Object.assign({
    url: value.url
  }, fileStatus(`${parakeetPath}/${value.file}`))]));
  return {
    dataDir: root,
    vad: Object.assign({ url: ASSET_FILES.vad.url }, fileStatus(resolvedVadPath())),
    parakeet: {
      path: parakeetPath,
      files: parakeetFiles,
      complete: Object.values(parakeetFiles).every(file => file.exists)
    },
    runtime: Object.assign({
      package: 'onnxruntime-node@1.27.0'
    }, fileStatus(runtimeModulePath()))
  };
}

async function remoteStatus(file) {
  try {
    const response = await fetch(file.url, { method: 'HEAD' });
    if (!response.ok) return { key: file.key, url: file.url, path: file.path, ok: false, error: `http ${response.status}`, updateAvailable: false };
    const remoteSize = Number(response.headers.get('content-length') || 0);
    const local = fileStatus(file.path);
    return {
      key: file.key,
      url: file.url,
      path: file.path,
      exists: local.exists,
      localSize: local.size,
      remoteSize,
      etag: response.headers.get('etag') || '',
      lastModified: response.headers.get('last-modified') || '',
      ok: true,
      updateAvailable: !local.exists || (remoteSize > 0 && local.size !== remoteSize)
    };
  } catch (error) {
    return { key: file.key, url: file.url, path: file.path, ok: false, error: String(error && error.message ? error.message : error), updateAvailable: false };
  }
}

async function checkAssets() {
  const assets = assetReport();
  const files = Object.fromEntries(await Promise.all(assetFiles(assets).map(async file => [file.key, await remoteStatus(file)])));
  return {
    checkedAt: new Date().toISOString(),
    assets,
    files,
    updateAvailable: Object.values(files).some(file => file.updateAvailable),
    unknown: Object.values(files).filter(file => !file.ok).map(file => file.key)
  };
}

function emitProgress(emit, event) {
  if (emit) emit(event);
}

async function writeStream(stream, path, emit, base) {
  const out = createWriteStream(path);
  let loaded = 0;
  for await (const chunk of Readable.fromWeb(stream)) {
    loaded += chunk.length;
    if (!out.write(chunk)) await new Promise(resolve => out.once('drain', resolve));
    emitProgress(emit, Object.assign({}, base, {
      event: 'download',
      loaded,
      progress: base.totalBytes > 0 ? loaded / base.totalBytes : base.progress
    }));
  }
  await new Promise((resolve, reject) => out.end(error => error ? reject(error) : resolve()));
}

async function downloadFile(url, path, options = {}) {
  if (options.force && existsSync(path)) unlinkSync(path);
  if (existsSync(path)) {
    emitProgress(options.emit, { event: 'skip', label: options.label || path, progress: options.progress || 1, path });
    return { path, skipped: true };
  }
  mkdirSync(path.slice(0, path.lastIndexOf('/')), { recursive: true });
  const tmp = `${path}.tmp-${process.pid}`;
  const response = await fetch(url);
  if (!response.ok || !response.body) throw new Error(`download failed ${response.status} ${url}`);
  const totalBytes = Number(response.headers.get('content-length') || 0);
  emitProgress(options.emit, { event: 'download-start', label: options.label || path, progress: options.progress || 0, totalBytes, path });
  await writeStream(response.body, tmp, options.emit, {
    label: options.label || path,
    progress: options.progress || 0,
    totalBytes,
    path
  });
  renameSync(tmp, path);
  emitProgress(options.emit, { event: 'download-done', label: options.label || path, progress: options.doneProgress || 1, path });
  return { path, skipped: false };
}

function installRuntime(options = {}) {
  emitProgress(options.emit, { event: 'runtime-start', label: 'runtime', progress: 0.08, path: runtimeModulePath() });
  if (existsSync(runtimeModulePath())) {
    emitProgress(options.emit, { event: 'runtime-done', label: 'runtime', progress: 0.18, path: runtimeModulePath(), skipped: true });
    return { path: runtimeModulePath(), skipped: true };
  }
  mkdirSync(runtimeDir(), { recursive: true });
  const result = spawnSync('npm', ['install', '--prefix', runtimeDir(), 'onnxruntime-node@1.27.0'], {
    encoding: 'utf8',
    maxBuffer: 1024 * 1024 * 8
  });
  if (result.status !== 0) throw new Error(`runtime install failed: ${(result.stderr || result.stdout || '').trim()}`);
  emitProgress(options.emit, { event: 'runtime-done', label: 'runtime', progress: 0.18, path: runtimeModulePath(), skipped: false });
  return { path: runtimeModulePath(), skipped: false };
}

async function installAssets(options = {}) {
  const report = assetReport();
  const results = [];
  const emit = options.emit;
  const files = assetFiles(report);
  emitProgress(emit, { event: 'start', label: options.force ? 'update' : 'install', progress: 0 });
  try {
    if (!options.force) results.push(installSystemDependencies({ emit }));
    results.push(installRuntime({ emit }));
    for (let i = 0; i < files.length; i++) {
      const start = 0.18 + i / files.length * 0.82;
      const end = 0.18 + (i + 1) / files.length * 0.82;
      results.push(await downloadFile(files[i].url, files[i].path, {
        label: files[i].key,
        progress: start,
        doneProgress: end,
        emit,
        force: options.force
      }));
    }
  } catch (error) {
    for (const file of [assetReport().vad].concat(Object.values(assetReport().parakeet.files))) {
      const tmp = `${file.path}.tmp-${process.pid}`;
      if (existsSync(tmp)) unlinkSync(tmp);
    }
    throw error;
  }
  const done = { event: 'done', progress: 1, installed: results, assets: assetReport() };
  emitProgress(emit, done);
  return { installed: results, assets: done.assets };
}

function jsonLine(event) {
  process.stdout.write(JSON.stringify(event) + '\n');
}

function recursiveNames(dir, prefix = '') {
  if (!dir || !existsSync(dir)) return [];
  const out = [];
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
    const name = prefix ? `${prefix}/${entry.name}` : entry.name;
    if (entry.isDirectory()) out.push(...recursiveNames(`${dir}/${entry.name}`, name));
    else out.push(name);
  }
  return out;
}

async function readable(path) {
  if (!path) return false;
  try {
    await access(path, constants.R_OK);
    return true;
  } catch (_error) {
    return false;
  }
}

async function doctor() {
  const vadPath = resolvedVadPath();
  const modelDir = resolvedParakeetDir();
  const modelFiles = recursiveNames(modelDir);
  const model = discoverParakeetDir(modelDir);
  const conceptualModel = Noko.validateParakeetModelFiles(modelFiles);
  const onnxRuntime = await loadOnnxRuntime().then(() => true, () => false);
  const sentencePiece = (() => {
    try {
      loadSentencePiece();
      return true;
    } catch (_error) {
      return false;
    }
  })();
  const vadDetails = vadPath && onnxRuntime ? await vadRuntimeReport(vadPath) : null;
  const systemDependencies = systemDependencyReport();
  const missingSystem = missingSystemDependencies(systemDependencies);
  const report = {
    systemDependencies,
    missingSystemDependencies: missingSystem,
    capture: {
      pwRecord: commandExists('pw-record'),
      parec: commandExists('parec')
    },
    vad: Object.assign({
      path: vadPath,
      readable: await readable(vadPath)
    }, vadDetails ? {
      loadable: vadDetails.loadable,
      inputNames: vadDetails.inputNames,
      outputNames: vadDetails.outputNames,
      stateful: vadDetails.stateful,
      srInput: vadDetails.srInput,
      frameSamples: vadDetails.frameSamples,
      smokeProbability: vadDetails.smokeProbability,
      error: vadDetails.error
    } : {}),
    parakeet: {
      path: modelDir,
      readable: await readable(modelDir),
      ok: model.ok,
      missing: model.missing,
      files: model.files,
      conceptualMissing: conceptualModel.missing
    },
    runtime: {
      onnxruntimeNode: onnxRuntime,
      sentencepieceJs: sentencePiece
    }
  };
  const needsSentencePiece = Boolean(model.files.tokenizer && model.files.tokenizer.endsWith('.model'));
  const parakeetRuntimeOk = report.runtime.onnxruntimeNode && (!needsSentencePiece || report.runtime.sentencepieceJs);
  report.parakeet.needsSentencePiece = needsSentencePiece;
  report.ok = missingSystem.length === 0 && report.vad.readable && report.parakeet.ok && parakeetRuntimeOk;
  return report;
}

function captureSamples(secondsArg, device) {
  const seconds = Math.max(0.03, Math.min(60, Number(secondsArg)));
  if (!isFinite(seconds)) throw new Error('seconds must be a number');
  const sampleCount = Math.round(seconds * Noko.TARGET_RATE);
  const argv = ['--raw', '--rate', String(Noko.TARGET_RATE), '--channels', '1', '--format', 'f32', '--sample-count', String(sampleCount)];
  if (device) argv.push('--target', String(device));
  argv.push('-');
  const result = spawnSync('pw-record', argv, {
    encoding: 'buffer',
    maxBuffer: Math.max(1024 * 1024, sampleCount * 8)
  });
  if (result.status !== 0 && result.stdout.length === 0) {
    const err = result.stderr ? result.stderr.toString('utf8').trim() : '';
    throw new Error(`pw-record failed${err ? `: ${err}` : ''}`);
  }
  return Noko.float32leToFloat32(result.stdout.subarray(0, sampleCount * 4), 1);
}

function capturePayload(secondsArg, device) {
  const samples = captureSamples(secondsArg, device);
  return {
    sampleRate: Noko.TARGET_RATE,
    channels: 1,
    format: 'float32-mono-json',
    samples: Array.from(samples)
  };
}

function captureJson(secondsArg, outputPath, device) {
  if (!secondsArg || !outputPath) throw new Error(usage());
  const payload = capturePayload(secondsArg, device);
  writeFileSync(outputPath, JSON.stringify(payload));
  return {
    outputPath,
    sampleRate: Noko.TARGET_RATE,
    channels: 1,
    sampleCount: payload.samples.length,
    duration: Math.round(payload.samples.length / Noko.TARGET_RATE * 1000) / 1000
  };
}

function normalizedSamplesFromJson(inputPath) {
  const input = JSON.parse(readFileSync(inputPath, 'utf8'));
  const mono = Noko.interleavedToMonoFloat32(new Float32Array(input.samples || []), input.channels || 1);
  return Noko.resampleLinear(mono, input.sampleRate || Noko.TARGET_RATE, Noko.TARGET_RATE);
}

function samplesFromRawFloat32(path) {
  return Noko.float32leToFloat32(readFileSync(path), 1);
}

async function vadJson(inputPath, outputPath) {
  if (!inputPath || !outputPath) throw new Error(usage());
  const samples = normalizedSamplesFromJson(inputPath);
  const vad = await OnnxVad.create(resolvedVadPath());
  const probabilities = await vadProbabilities(samples, { vad });
  writeFileSync(outputPath, JSON.stringify({
    sampleRate: Noko.TARGET_RATE,
    frameSamples: Noko.FRAME_SAMPLES,
    frameCount: probabilities.length,
    probabilities
  }));
  return {
    outputPath,
    frameSamples: Noko.FRAME_SAMPLES,
    frameCount: probabilities.length
  };
}

async function vadGateJson(inputPath, outputPath) {
  if (!inputPath || !outputPath) throw new Error(usage());
  const samples = normalizedSamplesFromJson(inputPath);
  const frames = Noko.splitFrames(samples, Noko.FRAME_SAMPLES);
  const vad = await OnnxVad.create(resolvedVadPath());
  const probabilities = await vadProbabilities(samples, { vad });
  const gated = Noko.vadGate(frames, probabilities, {
    threshold: 0.3,
    preRoll: 15,
    onset: 2,
    hangover: 15
  });
  writeFileSync(outputPath, JSON.stringify({
    sampleRate: Noko.TARGET_RATE,
    frameSamples: Noko.FRAME_SAMPLES,
    inputFrameCount: frames.length,
    speechFrameCount: gated.speechFrameCount,
    frameIndexes: gated.frameIndexes,
    probabilities,
    samples: Array.from(gated.samples)
  }));
  return {
    outputPath,
    inputFrameCount: frames.length,
    speechFrameCount: gated.speechFrameCount,
    sampleCount: gated.samples.length
  };
}

async function transcribeSamples(samples, historyPath) {
  if (!historyPath) throw new Error(usage());
  samples = samples instanceof Float32Array ? samples : new Float32Array(samples);
  const frames = Noko.splitFrames(samples, Noko.FRAME_SAMPLES);
  const vad = await OnnxVad.create(resolvedVadPath());
  const probabilities = await vadProbabilities(samples, { vad });
  const gated = Noko.vadGate(frames, probabilities, {
    threshold: 0.3,
    preRoll: 15,
    onset: 2,
    hangover: 15
  });
  if (gated.samples.length === 0) throw new Error('empty speech buffer after vad');
  const raw = await (await ParakeetOnnx.create(resolvedParakeetDir())).transcribe(gated.samples);
  const text = Noko.postprocess(raw && raw.text !== undefined ? raw.text : raw);
  if (!text) throw new Error('empty transcript');
  const record = Noko.historyRecord(text, gated.samples, Object.assign({
    runtimeProvider: 'onnxruntime-node'
  }, raw && raw.meta ? raw.meta : {}));
  appendFileSync(historyPath, JSON.stringify(record) + '\n');
  return {
    text,
    frameIndexes: gated.frameIndexes,
    history: record
  };
}

async function transcribeRealJson(inputPath, historyPath) {
  if (!inputPath || !historyPath) throw new Error(usage());
  return transcribeSamples(normalizedSamplesFromJson(inputPath), historyPath);
}

async function transcribeRawJson(inputPath, historyPath) {
  if (!inputPath || !historyPath) throw new Error(usage());
  return transcribeSamples(samplesFromRawFloat32(inputPath), historyPath);
}

async function transcribeCaptureJson(secondsArg, historyPath, device) {
  if (!secondsArg || !historyPath) throw new Error(usage());
  return transcribeSamples(capturePayload(secondsArg, device).samples, historyPath);
}

function transcribeJson(inputPath, historyPath) {
  if (!inputPath || !historyPath) throw new Error(usage());
  const input = JSON.parse(readFileSync(inputPath, 'utf8'));
  const probabilities = input.probabilities || [];
  const result = Noko.transcribePipeline({
    samples: new Float32Array(input.samples || []),
    sampleRate: input.sampleRate || Noko.TARGET_RATE,
    channels: input.channels || 1
  }, {
    probability: (_frame, index) => Number(probabilities[index] || 0)
  }, {
    transcribe: () => ({
      text: String(input.transcript || ''),
      meta: {
        model: input.model || 'json-fixture',
        quantization: input.quantization || 'fixture',
        runtimeProvider: 'fixture'
      }
    })
  }, {
    write: record => appendFileSync(historyPath, JSON.stringify(record) + '\n')
  }, input.options || {});
  return result;
}

async function main(argv) {
  const command = argv[2] || 'help';
  if (command === 'doctor') return doctor();
  if (command === 'system-deps-json') return systemDependencyStatus();
  if (command === 'assets-json') return assetReport();
  if (command === 'check-assets-json') return checkAssets();
  if (command === 'install-assets') return installAssets();
  if (command === 'update-assets') return installAssets({ force: true });
  if (command === 'install-assets-progress') {
    await installAssets({ emit: jsonLine });
    return null;
  }
  if (command === 'update-assets-progress') {
    await installAssets({ force: true, emit: jsonLine });
    return null;
  }
  if (command === 'capture-json') return captureJson(argv[3], argv[4], argv[5]);
  if (command === 'vad-json') return vadJson(argv[3], argv[4]);
  if (command === 'vad-gate-json') return vadGateJson(argv[3], argv[4]);
  if (command === 'transcribe-real-json') return transcribeRealJson(argv[3], argv[4]);
  if (command === 'transcribe-raw-json') return transcribeRawJson(argv[3], argv[4]);
  if (command === 'transcribe-capture-json') return transcribeCaptureJson(argv[3], argv[4], argv[5]);
  if (command === 'transcribe-json') return transcribeJson(argv[3], argv[4]);
  if (command === 'help' || command === '--help' || command === '-h') return usage();
  throw new Error(`unknown command: ${command}\n${usage()}`);
}

main(process.argv).then(result => {
  if (result === null || result === undefined) return;
  if (typeof result === 'string') {
    process.stdout.write(result);
    return;
  }
  process.stdout.write(JSON.stringify(result, null, 2) + '\n');
}).catch(error => {
  process.stderr.write(String(error && error.message ? error.message : error) + '\n');
  process.exit(1);
});