Skip to content
This repository has been archived by the owner on Nov 3, 2022. It is now read-only.

Commit

Permalink
feat: workspace-root config, implicit workspace
Browse files Browse the repository at this point in the history
Related-to: npm/cli#3596
  • Loading branch information
isaacs committed Aug 3, 2021
1 parent 359a001 commit 8f086a1
Show file tree
Hide file tree
Showing 4 changed files with 402 additions and 40 deletions.
28 changes: 28 additions & 0 deletions lib/find-project-dir.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const walkUp = require('walk-up-path')
const { relative, resolve, dirname, join } = require('path')
const { promisify } = require('util')
const fs = require('fs')
const stat = promisify(fs.stat)

// starting from the start dir, walk up until we hit the first
// folder with a node_modules or package.json. if none are found,
// then return the start dir itself.
module.exports = async (start, end = null) => {
for (const p of walkUp(start)) {
// walk up until we have a nm dir or a pj file
const hasAny = (await Promise.all([
stat(resolve(p, 'node_modules'))
.then(st => st.isDirectory())
.catch(() => false),
stat(resolve(p, 'package.json'))
.then(st => st.isFile())
.catch(() => false),
])).some(is => is)
if (hasAny)
return p
if (end && relative(p, end) === '')
break
}

return start
}
172 changes: 134 additions & 38 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
// TODO: set the scope config from package.json or explicit cli config
const walkUp = require('walk-up-path')
const ini = require('ini')
const nopt = require('nopt')
const mkdirp = require('mkdirp-infer-owner')
Expand All @@ -9,7 +8,8 @@ const myUid = process.getuid && process.getuid()
/* istanbul ignore next */
const myGid = process.getgid && process.getgid()

const { resolve, dirname, join } = require('path')
const findProjectDir = require('./find-project-dir.js')
const { relative, resolve, dirname, join } = require('path')
const { homedir } = require('os')
const { promisify } = require('util')
const fs = require('fs')
Expand Down Expand Up @@ -53,6 +53,8 @@ const confFileTypes = new Set([
'global',
'user',
'project',
// the place where we store 'workspace-root' and the implicit workspace
'workspace',
])

const confTypes = new Set([
Expand All @@ -65,9 +67,11 @@ const confTypes = new Set([

const _loaded = Symbol('loaded')
const _get = Symbol('get')
const _set = Symbol('set')
const _find = Symbol('find')
const _loadObject = Symbol('loadObject')
const _loadFile = Symbol('loadFile')
const _readFile = Symbol('readFile')
const _checkDeprecated = Symbol('checkDeprecated')
const _flatten = Symbol('flatten')
const _flatOptions = Symbol('flatOptions')
Expand Down Expand Up @@ -195,6 +199,13 @@ class Config {
set (key, val, where = 'cli') {
if (!this.loaded)
throw new Error('call config.load() before setting values')

if (key === 'workspace-root' && !['project', 'workspace'].includes(where))
throw new Error(`cannot set workspace-root in ${where} config`)

return this[_set](key, val, where)
}
[_set] (key, val, where) {
if (!confTypes.has(where))
throw new Error('invalid config location param: ' + where)
this[_checkDeprecated](key)
Expand Down Expand Up @@ -251,14 +262,17 @@ class Config {
process.emit('time', 'config:load:cli')
this.loadCLI()
process.emit('timeEnd', 'config:load:cli')

process.emit('time', 'config:load:env')
this.loadEnv()
process.emit('timeEnd', 'config:load:env')

// next project config, which can affect userconfig location
// if we have a workspace config, we end up loading it there, too.
process.emit('time', 'config:load:project')
await this.loadProjectConfig()
process.emit('timeEnd', 'config:load:project')

// then user config, which can affect globalconfig location
process.emit('time', 'config:load:user')
await this.loadUserConfig()
Expand Down Expand Up @@ -451,6 +465,16 @@ class Config {
throw new Error(m)
}

if (where === 'workspace' && obj) {
const keys = Object.keys(obj).filter(k => k !== 'workspace-root')
if (obj['workspace-root'] && keys.length) {
const m = 'workspace-root set, ignoring other workspace-level configs'
this.log.warn('config', m, keys)
for (const k of keys)
delete obj[k]
}
}

conf.source = source
this.sources.set(source, where)
if (er) {
Expand Down Expand Up @@ -482,13 +506,27 @@ class Config {
return parseField(f, key, this, listElement)
}

async [_readFile] (file) {
process.emit('time', 'config:load:readfile:' + file)

const [er, data] = await readFile(file, 'utf8')
.then(data => [null, ini.parse(data)], er => [er, null])

// workspace-root is relative to the file when set in a file, not cwd
// otherwise we get non-portable options set in saved ws project configs
// we set it back to a relative path when saving.
if (data && data['workspace-root']) {
data['workspace-root'] = resolve(dirname(file), data['workspace-root'])
}

process.emit('timeEnd', 'config:load:readfile:' + file)
return [er, data]
}

async [_loadFile] (file, type) {
process.emit('time', 'config:load:file:' + file)
// only catch the error from readFile, not from the loadObject call
await readFile(file, 'utf8').then(
data => this[_loadObject](ini.parse(data), type, file),
er => this[_loadObject](null, type, file, er)
)
const [er, data] = await this[_readFile](file)
this[_loadObject](data, type, file, er)
process.emit('timeEnd', 'config:load:file:' + file)
}

Expand All @@ -497,51 +535,85 @@ class Config {
}

async loadProjectConfig () {
// the localPrefix can be set by the CLI config, but otherwise is
// found by walking up the folder tree
await this.loadLocalPrefix()
const projectFile = resolve(this.localPrefix, '.npmrc')
// if --prefix is in cli, then that is our localPrefix, full stop
// in that case, we do not walk up the folder tree, do not define
// an implicit workspace, etc. We're done.
//
// walk up from cwd to the nearest nm/pj folder. this is the projectDir
//
// if we already have a workspace-root defined, then the workspace-root
// is the only place a "project" config can be. look there for it,
// set the workspace-root as our localPrefix. If the projectDir is the
// same as our localPrefix, then we're done, and there is no implicit
// workspace. Otherwise, the implicit workspace is the projectDir we
// walked up to. Done.
//
// check the projectDir for a .npmrc. If none found, then we have no
// project config, and no implicit workspace.
//
// If the projectDir/.npmrc sets workspace-root, then load it as the
// workspace config. resolve the workspace-root, and load the project
// config from that location.
//
// Any time that we set a workspace-root, we should *also* set the
// localPrefix.

// if we are currently in ~, then don't walk up past that backstop
const cliPrefix = this[_get]('prefix', 'cli')
const projectDir = cliPrefix || await findProjectDir(this.cwd)
const projectFile = resolve(projectDir, '.npmrc')

// if we're in the ~ directory, and there happens to be a node_modules
// folder (which is not TOO uncommon, it turns out), then we can end
// up loading the "project" config where the "userconfig" will be,
// which causes some calamaties. So, we only load project config if
// it doesn't match what the userconfig will be.
if (projectFile !== this[_get]('userconfig'))
return this[_loadFile](projectFile, 'project')
else {
this.data.get('project').source = '(same as "user" config, ignored)'
if (this[_get]('userconfig') && relative(projectFile, this[_get]('userconfig')) === '') {
this.localPrefix = projectDir
this.data.get('project').source = '(project same as "user" config, ignored)'
this.sources.set(this.data.get('project').source, 'project')
}
}

async loadLocalPrefix () {
const cliPrefix = this[_get]('prefix', 'cli')
if (cliPrefix) {
this.localPrefix = cliPrefix
this.data.get('workspace').source = '(workspace same as "user" config, ignored)'
this.sources.set(this.data.get('workspace').source, 'workspace')
return
}

for (const p of walkUp(this.cwd)) {
// walk up until we have a nm dir or a pj file
const hasAny = (await Promise.all([
stat(resolve(p, 'node_modules'))
.then(st => st.isDirectory())
.catch(() => false),
stat(resolve(p, 'package.json'))
.then(st => st.isFile())
.catch(() => false),
])).some(is => is)
if (hasAny) {
this.localPrefix = p
return
// if we already set a workspace root before, then we know that whatever
// that was set to is our localPrefix, and the current project (if it's
// different) is an implicit workspace.
const wsRootBefore = this[_get]('workspace-root')
if (wsRootBefore) {
this.localPrefix = wsRootBefore
await this[_loadFile](`${wsRootBefore}/.npmrc`, 'project')
if (projectDir !== wsRootBefore) {
await this[_loadFile](projectFile, 'workspace')
this[_set]('workspace', [relative(this.localPrefix, projectDir)], 'workspace')
}
return
}

this.localPrefix = this.cwd
// ok, we have to check to see if a workspace-root is set in this file
// if it is, then we need to actually load the REAL project configs
// from the effective workspace-root.
const [er, data] = await this[_readFile](projectFile)
const wsRoot = !er && data && data['workspace-root']
if (wsRoot) {
this.localPrefix = wsRoot
await this[_loadFile](resolve(wsRoot, '.npmrc'), 'project')
this[_loadObject](data, 'workspace', projectFile, er)
this[_set]('workspace', [relative(this.localPrefix, projectDir)], 'workspace')
return
} else {
this.localPrefix = projectDir
this[_loadObject](data, 'project', projectFile, er)
this.data.get('workspace').source = '(same as "project" config, ignored)'
this.sources.set(this.data.get('workspace').source, 'workspace')
return
}
}

loadUserConfig () {
return this[_loadFile](this[_get]('userconfig'), 'user')
const userconfig = this[_get]('userconfig') || resolve(this.home, '.npmrc')
return this[_loadFile](userconfig, 'user')
}

loadGlobalConfig () {
Expand All @@ -567,7 +639,30 @@ class Config {
try { this.setCredentialsByURI(reg, creds) } catch (_) {}
}

const iniData = ini.stringify(conf.data).trim() + '\n'
const data = { ...conf.data }
const { source } = conf

if (data['workspace-root']) {
const dir = dirname(source)
data['workspace-root'] = relative(dir, data['workspace-root'])
}

// do not save an empty workspace-root field in the project config
if (data['workspace-root'] === '') {
this.log.warn('config', 'Ignoring empty \'workspace-root\' config')
delete data['workspace-root']
}

if (data['workspace-root']) {
const keys = Object.keys(data).filter(k => k !== 'workspace-root')
if (keys.length) {
const er = new Error('Cannot set other configs with workspace-root')
this.log.verbose('config', 'trying to set other keys with workspace-root', keys)
throw Object.assign(er, { found: keys })
}
}

const iniData = ini.stringify(data).trim() + '\n'
if (!iniData.trim()) {
// ignore the unlink error (eg, if file doesn't exist)
await unlink(conf.source).catch(er => {})
Expand All @@ -583,6 +678,7 @@ class Config {
if (st && (st.uid !== myUid || st.gid !== myGid))
await chown(conf.source, st.uid, st.gid).catch(() => {})
}

const mode = where === 'user' ? 0o600 : 0o666
await chmod(conf.source, mode)
}
Expand Down
28 changes: 28 additions & 0 deletions test/find-project-dir.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const t = require('tap')
const findProjectDir = require('../lib/find-project-dir.js')
const { resolve } = require('path')

t.test('walk up, but do not pass end', async t => {
const path = t.testdir({
hasnm: {
node_modules: {},
a: { b: { c: {}}},
},
haspj: {
'package.json': JSON.stringify({}),
a: { b: { c: {}}},
},
})
t.equal(
await findProjectDir(resolve(`${path}/hasnm/a/b/c`)),
resolve(path, 'hasnm')
)
t.equal(
await findProjectDir(resolve(`${path}/haspj/a/b/c`)),
resolve(path, 'haspj')
)
t.equal(
await findProjectDir(resolve(`${path}/haspj/a/b/c`), resolve(path, 'haspj/a')),
resolve(path, 'haspj/a/b/c')
)
})
Loading

0 comments on commit 8f086a1

Please sign in to comment.