@ 261105.4
current
import path from 'node:path';
import os from 'node:os';

import { readJson, writeJson } from './file.js';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { forceRunAsync, runSync } from './run.js';

export const GLOBAL_CONFIG = Symbol('globalConfig');
export const PROJECT_CONFIG = Symbol('projectConfig');
export const LOCAL_CONFIG = Symbol('localConfig');

export function getNcurcPath() {
  if (process.env.XDG_CONFIG_HOME !== 'undefined' &&
      process.env.XDG_CONFIG_HOME !== undefined) {
    return path.join(process.env.XDG_CONFIG_HOME, 'ncurc');
  } else {
    return path.join(os.homedir(), '.ncurc');
  }
}

let mergedConfig;
export function getMergedConfig(dir, home, additional) {
  if (mergedConfig == null) {
    const globalConfig = getConfig(GLOBAL_CONFIG, home);
    const projectConfig = getConfig(PROJECT_CONFIG, dir);
    const localConfig = getConfig(LOCAL_CONFIG, dir);

    // Using property descriptors to avoid calling any setter if e.g. a
    // localConfig value overrides a globalConfig key.
    mergedConfig = Object.create(null, {
      ...Object.getOwnPropertyDescriptors(globalConfig),
      ...Object.getOwnPropertyDescriptors(projectConfig),
      ...Object.getOwnPropertyDescriptors(localConfig),
    });
  }
  return additional
    ? Object.create(null, {
      ...Object.getOwnPropertyDescriptors(mergedConfig),
      ...Object.getOwnPropertyDescriptors(additional),
    })
    : mergedConfig;
}
export function clearCachedConfig() {
  mergedConfig = null;
}

export async function encryptValue(input) {
  console.warn('Spawning gpg to encrypt the config value');
  return forceRunAsync(
    process.env.GPG_BIN || 'gpg',
    ['--default-recipient-self', '--encrypt', '--armor'],
    {
      captureStdout: true,
      ignoreFailure: false,
      input
    }
  );
}

function setOwnProperty(target, key, value) {
  return Object.defineProperty(target, key, {
    __proto__: null,
    configurable: true,
    enumerable: true,
    value
  });
}
function addEncryptedPropertyGetter(target, key, input) {
  if (input?.startsWith?.('-----BEGIN PGP MESSAGE-----\n')) {
    return Object.defineProperty(target, key, {
      __proto__: null,
      configurable: true,
      get() {
        // Using an error object to get a stack trace in debug mode.
        const warn = new Error(
          `The config value for ${key} is encrypted, spawning gpg to decrypt it...`
        );
        console.warn(setOwnProperty(warn, 'name', 'Warning'));
        const value = runSync(process.env.GPG_BIN || 'gpg', ['--decrypt'], { input });
        setOwnProperty(target, key, value);
        return value;
      },
      set(newValue) {
        if (!addEncryptedPropertyGetter(target, key, newValue)) {
          throw new Error(
            'Refusing to override an encrypted value with a non-encrypted one. ' +
            'Please use an encrypted one, or delete the config key first.'
          );
        }
      }
    });
  }
}

export function getConfig(configType, dir, raw = false) {
  const configPath = getConfigPath(configType, dir);
  const encryptedConfigPath = configPath + '.gpg';
  if (existsSync(encryptedConfigPath)) {
    console.warn('Encrypted config detected, spawning gpg to decrypt it...');
    const { status, stdout } =
      spawnSync(process.env.GPG_BIN || 'gpg', ['--decrypt', encryptedConfigPath]);
    if (status === 0) {
      return JSON.parse(stdout.toString('utf-8'));
    }
  }
  try {
    const json = readJson(configPath);
    if (!raw) {
      // Raw config means encrypted values are returned as is.
      // Otherwise we install getters to decrypt them when accessed.
      for (const [key, val] of Object.entries(json)) {
        addEncryptedPropertyGetter(json, key, val);
      }
    }
    return json;
  } catch (cause) {
    throw new Error('Unable to parse config file ' + configPath, { cause });
  }
}

function getConfigPath(configType, dir) {
  switch (configType) {
    case GLOBAL_CONFIG:
      return getNcurcPath();
    case PROJECT_CONFIG: {
      const projectRcPath = path.join(dir || process.cwd(), '.ncurc');
      return projectRcPath;
    }
    case LOCAL_CONFIG: {
      const ncuDir = getNcuDir(dir);
      const configPath = path.join(ncuDir, 'config');
      return configPath;
    }
    default:
      throw Error('Invalid configType');
  }
}

function writeConfig(configType, obj, dir) {
  const configPath = getConfigPath(configType, dir);
  const encryptedConfigPath = configPath + '.gpg';
  if (existsSync(encryptedConfigPath)) {
    const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'ncurc-'));
    const tmpFile = path.join(tmpDir, 'config.json');
    try {
      writeJson(tmpFile, obj);
      const { status } = spawnSync(process.env.GPG_BIN || 'gpg',
        ['--default-recipient-self', '--yes', '--encrypt', '--output', encryptedConfigPath, tmpFile]
      );
      if (status !== 0) {
        throw new Error('Failed to encrypt config file: ' + encryptedConfigPath);
      }
    } finally {
      rmSync(tmpDir, { recursive: true, force: true });
    }
    return encryptedConfigPath;
  }
  writeJson(configPath, obj);
  return configPath;
}

export function updateConfig(configType, obj, dir) {
  const config = getConfig(configType, dir, true);
  writeConfig(configType, Object.assign(config, obj), dir);
}

export function getHomeDir(home) {
  if (process.env.XDG_CONFIG_HOME) {
    return process.env.XDG_CONFIG_HOME;
  }
  return home || os.homedir();
}

export function getNcuDir(dir) {
  return path.join(dir || process.cwd(), '.ncu');
}