diff --git a/lib/commands/install.js b/lib/commands/install.js index 2fd9bc8d5cd7a..84970b5da6d9e 100644 --- a/lib/commands/install.js +++ b/lib/commands/install.js @@ -144,19 +144,6 @@ class Install extends ArboristWorkspaceCmd { throw this.usageError() } - const Arborist = require('@npmcli/arborist') - const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm) - const opts = { - ...this.npm.flatOptions, - auditLevel: null, - path: where, - add: args, - workspaces: this.workspaceNames, - allowScripts: allowScriptsPolicy, - // patch relax flags are honored only when passed on the command line - ...patchRelaxOpts(this.npm.config), - } - // Root lifecycle scripts only run for a bare `npm install` in a local project. `preinstall` runs *before* Arborist touches the filesystem so that scripts can bootstrap the environment (e.g. set up private-registry auth, generate files consumed during resolution) before dependencies are fetched or unpacked. The remaining scripts run after reify as they did before. const runRootLifecycle = !args.length && !isGlobalInstall && !ignoreScripts const runRootScript = (event) => runScript({ @@ -169,8 +156,21 @@ class Install extends ArboristWorkspaceCmd { if (runRootLifecycle) { await runRootScript('preinstall') + await this.npm.config.reload('user') } + const Arborist = require('@npmcli/arborist') + const { policy: allowScriptsPolicy } = await resolveAllowScripts(this.npm) + const opts = { + ...this.npm.flatOptions, + auditLevel: null, + path: where, + add: args, + workspaces: this.workspaceNames, + allowScripts: allowScriptsPolicy, + // patch relax flags are honored only when passed on the command line + ...patchRelaxOpts(this.npm.config), + } const arb = new Arborist(opts) await strictAllowScriptsPreflight({ arb, npm: this.npm, idealTreeOpts: opts }) await arb.reify(opts) diff --git a/tap-snapshots/test/lib/commands/install.js.test.cjs b/tap-snapshots/test/lib/commands/install.js.test.cjs index 9070c6299f03d..c363c868941c7 100644 --- a/tap-snapshots/test/lib/commands/install.js.test.cjs +++ b/tap-snapshots/test/lib/commands/install.js.test.cjs @@ -22,6 +22,7 @@ warn EBADDEVENGINES { warn EBADDEVENGINES current: { name: 'node', version: 'v1337.0.0' }, warn EBADDEVENGINES required: { name: 'node', version: '0.0.1', onFail: 'warn' } warn EBADDEVENGINES } +silly config load:file:{CWD}/home/.npmrc silly packumentCache heap:{heap} maxSize:{maxSize} maxEntrySize:{maxEntrySize} silly idealTree buildDeps silly reify moves {} @@ -170,6 +171,7 @@ warn EBADDEVENGINES { warn EBADDEVENGINES current: { name: 'x86' }, warn EBADDEVENGINES required: { name: 'risv', onFail: 'warn' } warn EBADDEVENGINES } +silly config load:file:{CWD}/home/.npmrc silly packumentCache heap:{heap} maxSize:{maxSize} maxEntrySize:{maxEntrySize} silly idealTree buildDeps silly reify moves {} @@ -256,6 +258,7 @@ warn EBADDEVENGINES { warn EBADDEVENGINES current: { name: 'node', version: 'v1337.0.0' }, warn EBADDEVENGINES required: { name: 'nondescript' } warn EBADDEVENGINES } +silly config load:file:{CWD}/home/.npmrc silly packumentCache heap:{heap} maxSize:{maxSize} maxEntrySize:{maxEntrySize} silly idealTree buildDeps silly reify moves {} @@ -275,6 +278,7 @@ verbose argv "--fetch-retries" "0" "--cache" "{CWD}/cache" "--loglevel" "silly" verbose logfile logs-max:10 dir:{CWD}/cache/_logs/{DATE}- verbose logfile {CWD}/cache/_logs/{DATE}-debug-0.log silly logfile done cleaning log files +silly config load:file:{CWD}/home/.npmrc silly packumentCache heap:{heap} maxSize:{maxSize} maxEntrySize:{maxEntrySize} silly idealTree buildDeps silly reify moves {} @@ -294,6 +298,7 @@ verbose argv "--fetch-retries" "0" "--cache" "{CWD}/cache" "--loglevel" "silly" verbose logfile logs-max:10 dir:{CWD}/cache/_logs/{DATE}- verbose logfile {CWD}/cache/_logs/{DATE}-debug-0.log silly logfile done cleaning log files +silly config load:file:{CWD}/home/.npmrc silly packumentCache heap:{heap} maxSize:{maxSize} maxEntrySize:{maxEntrySize} silly idealTree buildDeps warn EBADENGINE Unsupported engine { diff --git a/test/lib/commands/install.js b/test/lib/commands/install.js index 3961b7f02e0c1..d19cb296a3815 100644 --- a/test/lib/commands/install.js +++ b/test/lib/commands/install.js @@ -144,6 +144,40 @@ t.test('exec commands', async t => { t.equal(post.depInstalled, true, 'postinstall runs after dependencies are installed') }) + await t.test('preinstall can refresh user registry credentials', async t => { + const tokenKey = '//registry.example/:_authToken' + let reifyToken + const { npm, home } = await loadMockNpm(t, { + config: { audit: false }, + homeDir: { + '.npmrc': `${tokenKey}=expired-token`, + }, + prefixDir: { + 'package.json': JSON.stringify({ + name: '@npmcli/test-package', + version: '1.0.0', + scripts: { preinstall: 'refresh credentials' }, + }), + }, + mocks: { + '@npmcli/run-script': async (opts) => { + if (opts.path === npm.prefix && opts.event === 'preinstall') { + fs.writeFileSync(path.join(home, '.npmrc'), `${tokenKey}=fresh-token`) + } + }, + '@npmcli/arborist': function () { + this.reify = async opts => { + reifyToken = opts[tokenKey] + } + }, + '{LIB}/utils/reify-finish.js': async () => {}, + }, + }) + + await npm.exec('install') + t.equal(reifyToken, 'fresh-token', 'reify uses credentials written by preinstall') + }) + await t.test('without args, --ignore-scripts skips preinstall entirely', async t => { const events = [] const { npm, registry } = await loadMockNpm(t, { diff --git a/workspaces/config/README.md b/workspaces/config/README.md index 6a948d9b11a91..7ac0b002804fb 100644 --- a/workspaces/config/README.md +++ b/workspaces/config/README.md @@ -160,6 +160,14 @@ Load configuration from the various sources of information. Returns a `Promise` that resolves when configuration is loaded, and fails if a fatal error is encountered. +### `config.reload(where)` + +Reload an already-loaded `project`, `user`, or `global` configuration layer from its original file. +Values removed from the file are cleared, higher-priority layers keep their precedence, and the flattened options cache is invalidated. + +Returns a `Promise` that resolves when the selected layer has been reloaded. +The initial `config.load()` must complete first. + ### `config.find(key)` Find the effective place in the configuration levels a given key is set. diff --git a/workspaces/config/lib/index.js b/workspaces/config/lib/index.js index 4121c2a7a3840..d35e8560e9ccd 100644 --- a/workspaces/config/lib/index.js +++ b/workspaces/config/lib/index.js @@ -276,6 +276,23 @@ class Config { this.setEnvs() } + async reload (where) { + if (!this.loaded) { + throw new Error('call config.load() before reloading') + } + if (!confFileTypes.has(where)) { + throw new Error('invalid config location param: ' + where) + } + + const conf = this.data.get(where) + const source = conf.source + this.sources.delete(source) + conf.reset() + this.#unknownConfigs = this.#unknownConfigs.filter(entry => entry.where !== where) + this.#flatOptions = null + await this.#loadFile(source, where) + } + loadDefaults () { this.loadGlobalPrefix() this.loadHome() @@ -1094,6 +1111,16 @@ class ConfigData { get raw () { return this.#raw } + + reset () { + for (const key of Object.keys(this.#data)) { + delete this.#data[key] + } + this.#source = null + this.#raw = {} + this[_loadError] = null + this[_valid] = true + } } const getTypesFromDefinitions = (definitions) => { diff --git a/workspaces/config/test/index.js b/workspaces/config/test/index.js index 60941c7760985..7b72247551365 100644 --- a/workspaces/config/test/index.js +++ b/workspaces/config/test/index.js @@ -107,6 +107,9 @@ t.test('construct with no settings, get default values for stuff', t => { t.rejects(() => c.save('user'), { message: 'call config.load() before saving', }) + t.rejects(() => c.reload('user'), { + message: 'call config.load() before reloading', + }) t.throws(() => c.data.set('user', {}), { message: 'cannot change internal config data structure', }) @@ -1061,6 +1064,48 @@ t.test('setting basic auth creds and email', async t => { }, 'credentials saved and nerfed') }) +t.test('reload user config', async t => { + const registry = 'https://registry.example/' + const tokenKey = '//registry.example/:_authToken' + const path = t.testdir({ + npm: { npmrc: '' }, + project: { 'package.json': '{"name":"reload-user-config"}' }, + user: { '.npmrc': `${tokenKey}=old-token\nfoo=from-user\n` }, + }) + const userconfig = join(path, 'user/.npmrc') + const config = new Config({ + argv: ['node', __filename, `--userconfig=${userconfig}`], + cwd: join(path, 'project'), + definitions, + env: { HOME: join(path, 'user'), npm_config_foo: 'from-env' }, + flatten, + nerfDarts, + npmPath: join(path, 'npm'), + shorthands, + }) + + await config.load() + await t.rejects(() => config.reload('env'), { + message: 'invalid config location param: env', + }) + const originalFlat = config.flat + t.equal(config.getCredentialsByURI(registry).token, 'old-token') + t.equal(config.get('foo'), 'from-env', 'environment config has higher priority') + + fs.writeFileSync(userconfig, `${tokenKey}=new-token\nfoo=updated-user\n`) + await config.reload('user') + + t.equal(config.getCredentialsByURI(registry).token, 'new-token') + t.equal(config.get('foo'), 'from-env', 'reload preserves higher-priority config') + t.not(config.flat, originalFlat, 'reload invalidates flattened options') + t.equal(config.flat[tokenKey], 'new-token', 'flattened credentials are refreshed') + + fs.writeFileSync(userconfig, '') + await config.reload('user') + t.equal(config.getCredentialsByURI(registry).token, undefined, 'removed credentials are cleared') + t.equal(config.get('foo'), 'from-env', 'lower-priority removals do not affect environment config') +}) + t.test('setting username/password/email individually', async t => { const registry = 'https://registry.npmjs.org/' const path = t.testdir()