Skip to content
29 changes: 24 additions & 5 deletions bin/ncu-config.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
#!/usr/bin/env node

import * as readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';

import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';

import {
getConfig, updateConfig, GLOBAL_CONFIG, PROJECT_CONFIG, LOCAL_CONFIG
getConfig, updateConfig, GLOBAL_CONFIG, PROJECT_CONFIG, LOCAL_CONFIG,
encryptValue
} from '../lib/config.js';
import { setVerbosityFromEnv } from '../lib/verbosity.js';

Expand All @@ -13,10 +17,15 @@ setVerbosityFromEnv();
const args = yargs(hideBin(process.argv))
.completion('completion')
.command({
command: 'set <key> <value>',
command: 'set <key> [<value>]',
desc: 'Set a config variable',
builder: (yargs) => {
yargs
.option('encrypt', {
describe: 'Store the value encrypted using gpg',
alias: 'x',
type: 'boolean'
})
.positional('key', {
describe: 'key of the configuration',
type: 'string'
Expand Down Expand Up @@ -61,8 +70,6 @@ const args = yargs(hideBin(process.argv))
.conflicts('global', 'project')
.help();

const argv = args.parse();

function getConfigType(argv) {
if (argv.global) {
return { configName: 'global', configType: GLOBAL_CONFIG };
Expand All @@ -73,9 +80,19 @@ function getConfigType(argv) {
return { configName: 'local', configType: LOCAL_CONFIG };
}

function setHandler(argv) {
async function setHandler(argv) {
const { configName, configType } = getConfigType(argv);
const config = getConfig(configType);
if (!argv.value) {
const rl = readline.createInterface({ input, output });
argv.value = await rl.question('What value do you want to set? ');
rl.close();
} else if (argv.encrypt) {
console.warn('Passing sensitive config value via the shell is discouraged');
}
if (argv.encrypt) {
argv.value = await encryptValue(argv.value);
}
console.log(
`Updating ${configName} configuration ` +
`[${argv.key}]: ${config[argv.key]} -> ${argv.value}`);
Expand All @@ -96,6 +113,8 @@ function listHandler(argv) {
}
}

const argv = await args.parse();

if (!['get', 'set', 'list'].includes(argv._[0])) {
args.showHelp();
}
102 changes: 62 additions & 40 deletions lib/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ClientRequest } from 'node:http';

import ghauth from 'ghauth';

import { clearCachedConfig, getMergedConfig, getNcurcPath } from './config.js';
import { clearCachedConfig, encryptValue, getMergedConfig, getNcurcPath } from './config.js';

export default lazy(auth);

Expand Down Expand Up @@ -60,68 +60,90 @@ function encode(name, token) {
return Buffer.from(`${name}:${token}`).toString('base64');
}

function setOwnProperty(target, key, value) {
return Object.defineProperty(target, key, {
__proto__: null,
configurable: true,
enumerable: true,
value
});
}

// TODO: support jenkins only...or not necessary?
// TODO: make this a class with dependency (CLI) injectable for testing
async function auth(
options = { github: true },
githubAuth = ghauth) {
const result = {};
const result = {
get github() {
let username;
let token;
try {
({ username, token } = getMergedConfig());
} catch (e) {
// Ignore error and prompt
}

check(username, token);
const github = encode(username, token);
setOwnProperty(result, 'github', github);
return github;
},

get jenkins() {
const { username, jenkins_token } = getMergedConfig();
if (!username || !jenkins_token) {
errorExit(
'Get your Jenkins API token in https://ci.nodejs.org/me/security ' +
'and run the following command to add it to your ncu config: ' +
'ncu-config --global set -x jenkins_token'
);
};
check(username, jenkins_token);
const jenkins = encode(username, jenkins_token);
setOwnProperty(result, 'jenkins', jenkins);
return jenkins;
},

get h1() {
const { h1_username, h1_token } = getMergedConfig();
check(h1_username, h1_token);
const h1 = encode(h1_username, h1_token);
setOwnProperty(result, 'h1', h1);
return h1;
}
};
if (options.github) {
let username;
let token;
let config;
try {
({ username, token } = getMergedConfig());
} catch (e) {
// Ignore error and prompt
config = getMergedConfig();
} catch {
config = {};
}

if (!username || !token) {
if (!Object.hasOwn(config, 'token') || !Object.hasOwn(config, 'username')) {
process.stdout.write(
'If this is your first time running this command, ' +
'follow the instructions to create an access token' +
'. If you prefer to create it yourself on Github, ' +
'see https://github.com/nodejs/node-core-utils/blob/main/README.md.\n');
const credentials = await tryCreateGitHubToken(githubAuth);
username = credentials.user;
token = credentials.token;
const username = credentials.user;
let token;
try {
token = await encryptValue(credentials.token);
} catch (err) {
console.warn('Failed encrypt token, storing unencrypted instead');
token = credentials.token;
}
const json = JSON.stringify({ username, token }, null, 2);
fs.writeFileSync(getNcurcPath(), json, {
mode: 0o600 /* owner read/write */
});
// Try again reading the file
clearCachedConfig();
({ username, token } = getMergedConfig());
}
check(username, token);
result.github = encode(username, token);
}

if (options.jenkins) {
const { username, jenkins_token } = getMergedConfig();
if (!username || !jenkins_token) {
errorExit(
'Get your Jenkins API token in https://ci.nodejs.org/me/configure ' +
'and run the following command to add it to your ncu config: ' +
'ncu-config --global set jenkins_token TOKEN'
);
};
check(username, jenkins_token);
result.jenkins = encode(username, jenkins_token);
}

if (options.h1) {
const { h1_username, h1_token } = getMergedConfig();
if (!h1_username || !h1_token) {
errorExit(
'Get your HackerOne API token in ' +
'https://docs.hackerone.com/organizations/api-tokens.html ' +
'and run the following command to add it to your ncu config: ' +
'ncu-config --global set h1_token TOKEN or ' +
'ncu-config --global set h1_username USERNAME'
);
};
result.h1 = encode(h1_username, h1_token);
}
return result;
}

Expand Down
55 changes: 52 additions & 3 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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');
Expand All @@ -19,19 +20,63 @@ export function getNcurcPath() {
}

let mergedConfig;
export function getMergedConfig(dir, home) {
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);
mergedConfig = Object.assign(globalConfig, projectConfig, localConfig);
mergedConfig = Object.assign(globalConfig, projectConfig, localConfig, additional);
}
return 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) {
addEncryptedPropertyGetter(target, key, newValue) ||
setOwnProperty(target, key, newValue);
}
});
}
}

export function getConfig(configType, dir) {
const configPath = getConfigPath(configType, dir);
const encryptedConfigPath = configPath + '.gpg';
Expand All @@ -44,7 +89,11 @@ export function getConfig(configType, dir) {
}
}
try {
return readJson(configPath);
const json = readJson(configPath);
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 });
}
Expand Down
9 changes: 7 additions & 2 deletions lib/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default class Session {
this.cli = cli;
this.dir = dir;
this.prid = prid;
this.config = { ...getMergedConfig(this.dir), ...argv };
this.config = getMergedConfig(this.dir, undefined, argv);
this.gpgSign = argv?.['gpg-sign']
? (argv['gpg-sign'] === true ? ['-S'] : ['-S', argv['gpg-sign']])
: [];
Expand Down Expand Up @@ -126,7 +126,12 @@ export default class Session {
writeJson(this.sessionPath, {
state: STARTED,
prid: this.prid,
config: this.config
// Filter out getters, those are likely encrypted data we don't need on the session.
config: Object.entries(Object.getOwnPropertyDescriptors(this.config))
.reduce((pv, [key, desc]) => {
if (Object.hasOwn(desc, 'value')) pv[key] = desc.value;
return pv;
}, { __proto__: null }),
});
}

Expand Down
5 changes: 5 additions & 0 deletions test/fixtures/run-auth-github.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ async function mockCredentials(options) {
(async function() {
const { default: auth } = await import('../../lib/auth.js');
const authParams = await auth({ github: true }, mockCredentials);
if (typeof authParams === 'object' && authParams != null) {
for (const key of Object.getOwnPropertyNames(authParams)) {
if (key !== 'github') delete authParams[key];
}
}
process.stdout.write(`${JSON.stringify(authParams)}\n`);
})().catch(err => {
console.error(err);
Expand Down
13 changes: 8 additions & 5 deletions test/unit/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,16 @@ describe('auth', async function() {
it('asks for auth data if no ncurc is found', async function() {
await runAuthScript(
undefined,
[FIRST_TIME_MSG, MOCKED_TOKEN]
[FIRST_TIME_MSG, MOCKED_TOKEN],
/^Spawning gpg to encrypt the config value\r?\nError: spawn do-not-exist ENOENT(?:.*\n)+Failed encrypt token, storing unencrypted instead\r?\n$/
);
});

it('asks for auth data if ncurc is invalid json', async function() {
await runAuthScript(
{ HOME: 'this is not json' },
[FIRST_TIME_MSG, MOCKED_TOKEN]
[FIRST_TIME_MSG, MOCKED_TOKEN],
/^Spawning gpg to encrypt the config value\r?\nError: spawn do-not-exist ENOENT(?:.*\n)+Failed encrypt token, storing unencrypted instead\r?\n$/
);
});

Expand Down Expand Up @@ -117,7 +119,7 @@ describe('auth', async function() {
function runAuthScript(
ncurc = {}, expect = [], error = '', fixture = 'run-auth-github') {
return new Promise((resolve, reject) => {
const newEnv = { HOME: undefined, XDG_CONFIG_HOME: undefined };
const newEnv = { HOME: undefined, XDG_CONFIG_HOME: undefined, GPG_BIN: 'do-not-exist' };
if (ncurc.HOME === undefined) ncurc.HOME = ''; // HOME must always be set.
for (const envVar in ncurc) {
if (ncurc[envVar] === undefined) continue;
Expand Down Expand Up @@ -154,8 +156,9 @@ function runAuthScript(
});
proc.on('close', () => {
try {
assert.strictEqual(stderr, error);
assert.strictEqual(expect.length, 0);
if (typeof error === 'string') assert.strictEqual(stderr, error);
else assert.match(stderr, error);
assert.deepStrictEqual(expect, []);
if (newEnv.HOME) {
fs.rmSync(newEnv.HOME, { recursive: true, force: true });
}
Expand Down
Loading