From 46ed325acab6985fb1a394d76d97c3b7dcb96614 Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Sat, 22 Aug 2026 18:36:08 +0530 Subject: [PATCH 1/4] test(install): cover preinstall credential refresh --- test/lib/commands/install.js | 34 ++++++++++++++++++++++++++++ workspaces/config/test/index.js | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) 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/test/index.js b/workspaces/config/test/index.js index 60941c7760985..64691081eee2d 100644 --- a/workspaces/config/test/index.js +++ b/workspaces/config/test/index.js @@ -1061,6 +1061,45 @@ 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() + 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() From 973041a6032c2dd6f246179fb39dc1b7b60cd91f Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Sat, 22 Aug 2026 18:38:31 +0530 Subject: [PATCH 2/4] fix(install): reload user config after preinstall --- lib/commands/install.js | 26 +++++++++++++------------- workspaces/config/lib/index.js | 33 +++++++++++++++++++++++++++++++-- workspaces/config/test/index.js | 6 ++++++ 3 files changed, 50 insertions(+), 15 deletions(-) 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/workspaces/config/lib/index.js b/workspaces/config/lib/index.js index 4121c2a7a3840..fb75311a330da 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, false) + } + loadDefaults () { this.loadGlobalPrefix() this.loadHome() @@ -733,9 +750,11 @@ class Config { return parseField(f, key, this, listElement) } - async #loadFile (file, type) { + async #loadFile (file, type, logLoad = true) { // only catch the error from readFile, not from the loadObject call - log.silly('config', `load:file:${file}`) + if (logLoad) { + log.silly('config', `load:file:${file}`) + } await readFile(file, 'utf8').then( data => { const parsedConfig = ini.parse(data) @@ -1094,6 +1113,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 64691081eee2d..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', }) @@ -1082,6 +1085,9 @@ t.test('reload user config', async t => { }) 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') From 10395b4937b6046a468c0ba2f001858ed22e3c06 Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Sat, 22 Aug 2026 18:42:53 +0530 Subject: [PATCH 3/4] test(install): record user config reload --- tap-snapshots/test/lib/commands/install.js.test.cjs | 5 +++++ workspaces/config/lib/index.js | 8 +++----- 2 files changed, 8 insertions(+), 5 deletions(-) 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/workspaces/config/lib/index.js b/workspaces/config/lib/index.js index fb75311a330da..d35e8560e9ccd 100644 --- a/workspaces/config/lib/index.js +++ b/workspaces/config/lib/index.js @@ -290,7 +290,7 @@ class Config { conf.reset() this.#unknownConfigs = this.#unknownConfigs.filter(entry => entry.where !== where) this.#flatOptions = null - await this.#loadFile(source, where, false) + await this.#loadFile(source, where) } loadDefaults () { @@ -750,11 +750,9 @@ class Config { return parseField(f, key, this, listElement) } - async #loadFile (file, type, logLoad = true) { + async #loadFile (file, type) { // only catch the error from readFile, not from the loadObject call - if (logLoad) { - log.silly('config', `load:file:${file}`) - } + log.silly('config', `load:file:${file}`) await readFile(file, 'utf8').then( data => { const parsedConfig = ini.parse(data) From 4c0efc07bebdae3f9f8ad6d773582a989d479265 Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Sat, 22 Aug 2026 18:44:39 +0530 Subject: [PATCH 4/4] docs(config): document file-layer reload --- workspaces/config/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) 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.