Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 16 additions & 18 deletions lib/commands/ci.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,8 @@ class CI extends ArboristWorkspaceCmd {
})
}

const dryRun = this.npm.config.get('dry-run')
const ignoreScripts = this.npm.config.get('ignore-scripts')
const where = this.npm.prefix
const Arborist = require('@npmcli/arborist')
const opts = {
Expand All@@ -46,38 +48,35 @@ class CI extends ArboristWorkspaceCmd {
workspaces: this.workspaceNames,
}

const arb = new Arborist(opts)
await arb.loadVirtual().catch(er => {
log.verbose('loadVirtual', er.stack)
// generate an inventory from the virtual tree in the lockfile
const virtualArb = new Arborist(opts)
try {
await virtualArb.loadVirtual()
} catch (err) {
log.verbose('loadVirtual', err.stack)
const msg =
'The `npm ci` command can only install with an existing package-lock.json or\n' +
'npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or\n' +
'later to generate a package-lock.json file, then try again.'
throw this.usageError(msg)
})

// retrieves inventory of packages from loaded virtual tree (lock file)
const virtualInventory = new Map(arb.virtualTree.inventory)
}
const virtualInventory = new Map(virtualArb.virtualTree.inventory)

// build ideal tree step needs to come right after retrieving the virtual
// inventory since it's going to erase the previous ref to virtualTree
// Now we make our real Arborist.
// We need a new one because the virtual tree fromt the lockfile can have extraneous dependencies in it that won't install on this platform
const arb = new Arborist(opts)
await arb.buildIdealTree()

// verifies that the packages from the ideal tree will match
// the same versions that are present in the virtual tree (lock file)
// throws a validation error in case of mismatches
// Verifies that the packages from the ideal tree will match the same versions that are present in the virtual tree (lock file).
const errors = validateLockfile(virtualInventory, arb.idealTree.inventory)
if (errors.length) {
throw this.usageError(
'`npm ci` can only install packages when your package.json and ' +
'package-lock.json or npm-shrinkwrap.json are in sync. Please ' +
'update your lock file with `npm install` ' +
'before continuing.\n\n' +
'`npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. ' +
'Please update your lock file with `npm install` before continuing.\n\n' +
errors.join('\n')
)
}

const dryRun = this.npm.config.get('dry-run')
if (!dryRun) {
const workspacePaths = await getWorkspaces([], {
path: this.npm.localPrefix,
Expand All@@ -100,7 +99,6 @@ class CI extends ArboristWorkspaceCmd {

await arb.reify(opts)

const ignoreScripts = this.npm.config.get('ignore-scripts')
// run the same set of scripts that `npm install` runs.
if (!ignoreScripts) {
const scripts = [
Expand Down
3 changes: 2 additions & 1 deletion lib/utils/audit-error.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ const { redactLog: replaceInfo } = require('@npmcli/redact')
// returns 'true' if there was an error, false otherwise

const auditError = (npm, report) => {
if (!report || !report.error) {
if (!report?.error) {
return false
}

Expand All@@ -34,6 +34,7 @@ const auditError = (npm, report) => {
output.standard(body)
}

// XXX we should throw a real error here
throw 'audit endpoint returned an error'
}

Expand Down
34 changes: 11 additions & 23 deletions lib/utils/reify-finish.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,30 +4,18 @@ const { writeFile } = require('node:fs/promises')
const { resolve } = require('node:path')

const reifyFinish = async (npm, arb) => {
await saveBuiltinConfig(npm, arb)
reifyOutput(npm, arb)
}

const saveBuiltinConfig = async (npm, arb) => {
const { options: { global }, actualTree } = arb
if (!global) {
return
}

// if we are using a builtin config, and just installed npm as
// a top-level global package, we have to preserve that config.
const npmNode = actualTree.inventory.get('node_modules/npm')
if (!npmNode) {
return
// if we are using a builtin config, and just installed npm as a top-level global package, we have to preserve that config.
if (arb.options.global) {
const npmNode = arb.actualTree.inventory.get('node_modules/npm')
if (npmNode) {
const builtinConf = npm.config.data.get('builtin')
if (!builtinConf.loadError) {
const content = ini.stringify(builtinConf.raw).trim() + '\n'
await writeFile(resolve(npmNode.path, 'npmrc'), content)
}
}
}

const builtinConf = npm.config.data.get('builtin')
if (builtinConf.loadError) {
return
}

const content = ini.stringify(builtinConf.raw).trim() + '\n'
await writeFile(resolve(npmNode.path, 'npmrc'), content)
reifyOutput(npm, arb)
}

module.exports = reifyFinish
4 changes: 1 addition & 3 deletions lib/utils/reify-output.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,9 +20,7 @@ const auditError = require('./audit-error.js')
const reifyOutput = (npm, arb) => {
const { diff, actualTree } = arb

// note: fails and crashes if we're running audit fix and there was an error
// which is a good thing, because there's no point printing all this other
// stuff in that case!
// note: fails and crashes if we're running audit fix and there was an error which is a good thing, because there's no point printing all this other stuff in that case!
const auditReport = auditError(npm, arb.auditReport) ? null : arb.auditReport

// don't print any info in --silent mode, but we still need to
Expand Down
29 changes: 25 additions & 4 deletions workspaces/arborist/lib/arborist/isolated-reifier.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ const getKey = (startNode) => {

module.exports = cls => class IsolatedReifier extends cls {
#externalProxies = new Map()
#omit = new Set()
#rootDeclaredDeps = new Set()
#processedEdges = new Set()
#workspaceProxies = new Map()
Expand DownExpand Up@@ -72,15 +73,18 @@ module.exports = cls => class IsolatedReifier extends cls {
**/
async makeIdealGraph () {
const idealTree = this.idealTree
const omit = new Set(this.options.omit)
this.#omit = new Set(this.options.omit)
const omit = this.#omit

// npm auto-creates 'workspace' edges from root to all workspaces.
// For isolated/linked mode, only include workspaces that root explicitly declares as dependencies.
// When omitting dep types, exclude those from the declared set so their workspaces aren't hoisted.
const rootPkg = idealTree.package
this.#rootDeclaredDeps = new Set([
...Object.keys(rootPkg.dependencies || {}),
...Object.keys(rootPkg.devDependencies || {}),
...Object.keys(rootPkg.optionalDependencies || {}),
...(!omit.has('dev') ? Object.keys(rootPkg.devDependencies || {}) : []),
...(!omit.has('optional') ? Object.keys(rootPkg.optionalDependencies || {}) : []),
...(!omit.has('peer') ? Object.keys(rootPkg.peerDependencies || {}) : []),
])

// XXX this sometimes acts like a node too
Expand DownExpand Up@@ -195,10 +199,27 @@ module.exports = cls => class IsolatedReifier extends cls {
return
}

const edges = [...node.edgesOut.values()].filter(edge =>
let edges = [...node.edgesOut.values()].filter(edge =>
edge.to?.target &&
!(node.package.bundledDependencies || node.package.bundleDependencies)?.includes(edge.to.name)
)

// Only omit edge types for root and workspace nodes (matching shouldOmit scope)
if ((node.isProjectRoot || node.isWorkspace) && this.#omit.size) {
edges = edges.filter(edge => {
if (edge.dev && this.#omit.has('dev')) {
return false
}
if (edge.optional && this.#omit.has('optional')) {
return false
}
if (edge.peer && this.#omit.has('peer')) {
return false
}
return true
})
}

let nonOptionalDeps = edges.filter(edge => !edge.optional).map(edge => edge.to.target)

// npm auto-creates 'workspace' edges from root to all workspaces.
Expand Down
133 changes: 133 additions & 0 deletions workspaces/arborist/test/isolated-mode.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -2155,6 +2155,139 @@ tap.test('omit dev dependencies with linked strategy', async t => {
t.notOk(storeEntries.some(e => e.startsWith('eslint@')), 'dev dep eslint is not in store')
})

tap.test('omit dev deps from root even when shared with workspace prod deps', async t => {
// In a monorepo, a root devDependency may also be a workspace prod dependency.
// With --omit=dev, root should NOT link to it, but the workspace still should.
// Also covers the case where a workspace itself is a root devDependency.
const graph = {
registry: [
{ name: 'typescript', version: '5.0.0' },
{ name: 'which', version: '1.0.0', dependencies: { isexe: '^1.0.0' } },
{ name: 'isexe', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0', mylib: '1.0.0' },
devDependencies: { typescript: '5.0.0', devtool: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { typescript: '5.0.0' },
},
{
name: 'devtool',
version: '1.0.0',
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['dev'],
})
await arborist.reify({ installStrategy: 'linked' })

const storeDir = path.join(dir, 'node_modules', '.store')
const storeEntries = fs.readdirSync(storeDir)

// typescript should still be in the store because mylib needs it as a prod dep
t.ok(storeEntries.some(e => e.startsWith('typescript@')), 'typescript is in store (workspace prod dep)')
t.ok(storeEntries.some(e => e.startsWith('which@')), 'which is in store')

// root should NOT have a symlink to typescript (it's a dev dep of root)
const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has symlink to prod dep which')
t.ok(rootNmEntries.includes('mylib'), 'root has symlink to prod workspace mylib')
t.notOk(rootNmEntries.includes('typescript'), 'root does not have symlink to dev dep typescript')
t.notOk(rootNmEntries.includes('devtool'), 'root does not have symlink to dev workspace devtool')

// workspace should have a symlink to typescript (it's a prod dep of mylib)
const wsNmEntries = fs.readdirSync(path.join(dir, 'packages', 'mylib', 'node_modules'))
t.ok(wsNmEntries.includes('typescript'), 'workspace has symlink to prod dep typescript')
})

tap.test('omit optional dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'fsevents', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
optionalDependencies: { fsevents: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { fsevents: '1.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['optional'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('fsevents'), 'root does not have optional dep fsevents')
})

tap.test('omit peer dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'react', version: '18.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
peerDependencies: { react: '18.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { react: '18.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['peer'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('react'), 'root does not have peer dep react')
})

/*
* TO TEST:
* --------------------------------------
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
[pull] latest from npm:latest by pull[bot] · Pull Request #150 · LadyK-21/cli · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 16 additions & 18 deletions lib/commands/ci.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,8 @@ class CI extends ArboristWorkspaceCmd {
})
}

const dryRun = this.npm.config.get('dry-run')
const ignoreScripts = this.npm.config.get('ignore-scripts')
const where = this.npm.prefix
const Arborist = require('@npmcli/arborist')
const opts = {
Expand All@@ -46,38 +48,35 @@ class CI extends ArboristWorkspaceCmd {
workspaces: this.workspaceNames,
}

const arb = new Arborist(opts)
await arb.loadVirtual().catch(er => {
log.verbose('loadVirtual', er.stack)
// generate an inventory from the virtual tree in the lockfile
const virtualArb = new Arborist(opts)
try {
await virtualArb.loadVirtual()
} catch (err) {
log.verbose('loadVirtual', err.stack)
const msg =
'The `npm ci` command can only install with an existing package-lock.json or\n' +
'npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or\n' +
'later to generate a package-lock.json file, then try again.'
throw this.usageError(msg)
})

// retrieves inventory of packages from loaded virtual tree (lock file)
const virtualInventory = new Map(arb.virtualTree.inventory)
}
const virtualInventory = new Map(virtualArb.virtualTree.inventory)

// build ideal tree step needs to come right after retrieving the virtual
// inventory since it's going to erase the previous ref to virtualTree
// Now we make our real Arborist.
// We need a new one because the virtual tree fromt the lockfile can have extraneous dependencies in it that won't install on this platform
const arb = new Arborist(opts)
await arb.buildIdealTree()

// verifies that the packages from the ideal tree will match
// the same versions that are present in the virtual tree (lock file)
// throws a validation error in case of mismatches
// Verifies that the packages from the ideal tree will match the same versions that are present in the virtual tree (lock file).
const errors = validateLockfile(virtualInventory, arb.idealTree.inventory)
if (errors.length) {
throw this.usageError(
'`npm ci` can only install packages when your package.json and ' +
'package-lock.json or npm-shrinkwrap.json are in sync. Please ' +
'update your lock file with `npm install` ' +
'before continuing.\n\n' +
'`npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. ' +
'Please update your lock file with `npm install` before continuing.\n\n' +
errors.join('\n')
)
}

const dryRun = this.npm.config.get('dry-run')
if (!dryRun) {
const workspacePaths = await getWorkspaces([], {
path: this.npm.localPrefix,
Expand All@@ -100,7 +99,6 @@ class CI extends ArboristWorkspaceCmd {

await arb.reify(opts)

const ignoreScripts = this.npm.config.get('ignore-scripts')
// run the same set of scripts that `npm install` runs.
if (!ignoreScripts) {
const scripts = [
Expand Down
3 changes: 2 additions & 1 deletion lib/utils/audit-error.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ const { redactLog: replaceInfo } = require('@npmcli/redact')
// returns 'true' if there was an error, false otherwise

const auditError = (npm, report) => {
if (!report || !report.error) {
if (!report?.error) {
return false
}

Expand All@@ -34,6 +34,7 @@ const auditError = (npm, report) => {
output.standard(body)
}

// XXX we should throw a real error here
throw 'audit endpoint returned an error'
}

Expand Down
34 changes: 11 additions & 23 deletions lib/utils/reify-finish.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,30 +4,18 @@ const { writeFile } = require('node:fs/promises')
const { resolve } = require('node:path')

const reifyFinish = async (npm, arb) => {
await saveBuiltinConfig(npm, arb)
reifyOutput(npm, arb)
}

const saveBuiltinConfig = async (npm, arb) => {
const { options: { global }, actualTree } = arb
if (!global) {
return
}

// if we are using a builtin config, and just installed npm as
// a top-level global package, we have to preserve that config.
const npmNode = actualTree.inventory.get('node_modules/npm')
if (!npmNode) {
return
// if we are using a builtin config, and just installed npm as a top-level global package, we have to preserve that config.
if (arb.options.global) {
const npmNode = arb.actualTree.inventory.get('node_modules/npm')
if (npmNode) {
const builtinConf = npm.config.data.get('builtin')
if (!builtinConf.loadError) {
const content = ini.stringify(builtinConf.raw).trim() + '\n'
await writeFile(resolve(npmNode.path, 'npmrc'), content)
}
}
}

const builtinConf = npm.config.data.get('builtin')
if (builtinConf.loadError) {
return
}

const content = ini.stringify(builtinConf.raw).trim() + '\n'
await writeFile(resolve(npmNode.path, 'npmrc'), content)
reifyOutput(npm, arb)
}

module.exports = reifyFinish
4 changes: 1 addition & 3 deletions lib/utils/reify-output.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,9 +20,7 @@ const auditError = require('./audit-error.js')
const reifyOutput = (npm, arb) => {
const { diff, actualTree } = arb

// note: fails and crashes if we're running audit fix and there was an error
// which is a good thing, because there's no point printing all this other
// stuff in that case!
// note: fails and crashes if we're running audit fix and there was an error which is a good thing, because there's no point printing all this other stuff in that case!
const auditReport = auditError(npm, arb.auditReport) ? null : arb.auditReport

// don't print any info in --silent mode, but we still need to
Expand Down
29 changes: 25 additions & 4 deletions workspaces/arborist/lib/arborist/isolated-reifier.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ const getKey = (startNode) => {

module.exports = cls => class IsolatedReifier extends cls {
#externalProxies = new Map()
#omit = new Set()
#rootDeclaredDeps = new Set()
#processedEdges = new Set()
#workspaceProxies = new Map()
Expand DownExpand Up@@ -72,15 +73,18 @@ module.exports = cls => class IsolatedReifier extends cls {
**/
async makeIdealGraph () {
const idealTree = this.idealTree
const omit = new Set(this.options.omit)
this.#omit = new Set(this.options.omit)
const omit = this.#omit

// npm auto-creates 'workspace' edges from root to all workspaces.
// For isolated/linked mode, only include workspaces that root explicitly declares as dependencies.
// When omitting dep types, exclude those from the declared set so their workspaces aren't hoisted.
const rootPkg = idealTree.package
this.#rootDeclaredDeps = new Set([
...Object.keys(rootPkg.dependencies || {}),
...Object.keys(rootPkg.devDependencies || {}),
...Object.keys(rootPkg.optionalDependencies || {}),
...(!omit.has('dev') ? Object.keys(rootPkg.devDependencies || {}) : []),
...(!omit.has('optional') ? Object.keys(rootPkg.optionalDependencies || {}) : []),
...(!omit.has('peer') ? Object.keys(rootPkg.peerDependencies || {}) : []),
])

// XXX this sometimes acts like a node too
Expand DownExpand Up@@ -195,10 +199,27 @@ module.exports = cls => class IsolatedReifier extends cls {
return
}

const edges = [...node.edgesOut.values()].filter(edge =>
let edges = [...node.edgesOut.values()].filter(edge =>
edge.to?.target &&
!(node.package.bundledDependencies || node.package.bundleDependencies)?.includes(edge.to.name)
)

// Only omit edge types for root and workspace nodes (matching shouldOmit scope)
if ((node.isProjectRoot || node.isWorkspace) && this.#omit.size) {
edges = edges.filter(edge => {
if (edge.dev && this.#omit.has('dev')) {
return false
}
if (edge.optional && this.#omit.has('optional')) {
return false
}
if (edge.peer && this.#omit.has('peer')) {
return false
}
return true
})
}

let nonOptionalDeps = edges.filter(edge => !edge.optional).map(edge => edge.to.target)

// npm auto-creates 'workspace' edges from root to all workspaces.
Expand Down
133 changes: 133 additions & 0 deletions workspaces/arborist/test/isolated-mode.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -2155,6 +2155,139 @@ tap.test('omit dev dependencies with linked strategy', async t => {
t.notOk(storeEntries.some(e => e.startsWith('eslint@')), 'dev dep eslint is not in store')
})

tap.test('omit dev deps from root even when shared with workspace prod deps', async t => {
// In a monorepo, a root devDependency may also be a workspace prod dependency.
// With --omit=dev, root should NOT link to it, but the workspace still should.
// Also covers the case where a workspace itself is a root devDependency.
const graph = {
registry: [
{ name: 'typescript', version: '5.0.0' },
{ name: 'which', version: '1.0.0', dependencies: { isexe: '^1.0.0' } },
{ name: 'isexe', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0', mylib: '1.0.0' },
devDependencies: { typescript: '5.0.0', devtool: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { typescript: '5.0.0' },
},
{
name: 'devtool',
version: '1.0.0',
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['dev'],
})
await arborist.reify({ installStrategy: 'linked' })

const storeDir = path.join(dir, 'node_modules', '.store')
const storeEntries = fs.readdirSync(storeDir)

// typescript should still be in the store because mylib needs it as a prod dep
t.ok(storeEntries.some(e => e.startsWith('typescript@')), 'typescript is in store (workspace prod dep)')
t.ok(storeEntries.some(e => e.startsWith('which@')), 'which is in store')

// root should NOT have a symlink to typescript (it's a dev dep of root)
const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has symlink to prod dep which')
t.ok(rootNmEntries.includes('mylib'), 'root has symlink to prod workspace mylib')
t.notOk(rootNmEntries.includes('typescript'), 'root does not have symlink to dev dep typescript')
t.notOk(rootNmEntries.includes('devtool'), 'root does not have symlink to dev workspace devtool')

// workspace should have a symlink to typescript (it's a prod dep of mylib)
const wsNmEntries = fs.readdirSync(path.join(dir, 'packages', 'mylib', 'node_modules'))
t.ok(wsNmEntries.includes('typescript'), 'workspace has symlink to prod dep typescript')
})

tap.test('omit optional dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'fsevents', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
optionalDependencies: { fsevents: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { fsevents: '1.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['optional'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('fsevents'), 'root does not have optional dep fsevents')
})

tap.test('omit peer dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'react', version: '18.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
peerDependencies: { react: '18.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { react: '18.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['peer'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('react'), 'root does not have peer dep react')
})

/*
* TO TEST:
* --------------------------------------
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [pull] latest from npm:latest by pull[bot] · Pull Request #150 · LadyK-21/cli · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 16 additions & 18 deletions lib/commands/ci.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,8 @@ class CI extends ArboristWorkspaceCmd {
})
}

const dryRun = this.npm.config.get('dry-run')
const ignoreScripts = this.npm.config.get('ignore-scripts')
const where = this.npm.prefix
const Arborist = require('@npmcli/arborist')
const opts = {
Expand All@@ -46,38 +48,35 @@ class CI extends ArboristWorkspaceCmd {
workspaces: this.workspaceNames,
}

const arb = new Arborist(opts)
await arb.loadVirtual().catch(er => {
log.verbose('loadVirtual', er.stack)
// generate an inventory from the virtual tree in the lockfile
const virtualArb = new Arborist(opts)
try {
await virtualArb.loadVirtual()
} catch (err) {
log.verbose('loadVirtual', err.stack)
const msg =
'The `npm ci` command can only install with an existing package-lock.json or\n' +
'npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or\n' +
'later to generate a package-lock.json file, then try again.'
throw this.usageError(msg)
})

// retrieves inventory of packages from loaded virtual tree (lock file)
const virtualInventory = new Map(arb.virtualTree.inventory)
}
const virtualInventory = new Map(virtualArb.virtualTree.inventory)

// build ideal tree step needs to come right after retrieving the virtual
// inventory since it's going to erase the previous ref to virtualTree
// Now we make our real Arborist.
// We need a new one because the virtual tree fromt the lockfile can have extraneous dependencies in it that won't install on this platform
const arb = new Arborist(opts)
await arb.buildIdealTree()

// verifies that the packages from the ideal tree will match
// the same versions that are present in the virtual tree (lock file)
// throws a validation error in case of mismatches
// Verifies that the packages from the ideal tree will match the same versions that are present in the virtual tree (lock file).
const errors = validateLockfile(virtualInventory, arb.idealTree.inventory)
if (errors.length) {
throw this.usageError(
'`npm ci` can only install packages when your package.json and ' +
'package-lock.json or npm-shrinkwrap.json are in sync. Please ' +
'update your lock file with `npm install` ' +
'before continuing.\n\n' +
'`npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. ' +
'Please update your lock file with `npm install` before continuing.\n\n' +
errors.join('\n')
)
}

const dryRun = this.npm.config.get('dry-run')
if (!dryRun) {
const workspacePaths = await getWorkspaces([], {
path: this.npm.localPrefix,
Expand All@@ -100,7 +99,6 @@ class CI extends ArboristWorkspaceCmd {

await arb.reify(opts)

const ignoreScripts = this.npm.config.get('ignore-scripts')
// run the same set of scripts that `npm install` runs.
if (!ignoreScripts) {
const scripts = [
Expand Down
3 changes: 2 additions & 1 deletion lib/utils/audit-error.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ const { redactLog: replaceInfo } = require('@npmcli/redact')
// returns 'true' if there was an error, false otherwise

const auditError = (npm, report) => {
if (!report || !report.error) {
if (!report?.error) {
return false
}

Expand All@@ -34,6 +34,7 @@ const auditError = (npm, report) => {
output.standard(body)
}

// XXX we should throw a real error here
throw 'audit endpoint returned an error'
}

Expand Down
34 changes: 11 additions & 23 deletions lib/utils/reify-finish.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,30 +4,18 @@ const { writeFile } = require('node:fs/promises')
const { resolve } = require('node:path')

const reifyFinish = async (npm, arb) => {
await saveBuiltinConfig(npm, arb)
reifyOutput(npm, arb)
}

const saveBuiltinConfig = async (npm, arb) => {
const { options: { global }, actualTree } = arb
if (!global) {
return
}

// if we are using a builtin config, and just installed npm as
// a top-level global package, we have to preserve that config.
const npmNode = actualTree.inventory.get('node_modules/npm')
if (!npmNode) {
return
// if we are using a builtin config, and just installed npm as a top-level global package, we have to preserve that config.
if (arb.options.global) {
const npmNode = arb.actualTree.inventory.get('node_modules/npm')
if (npmNode) {
const builtinConf = npm.config.data.get('builtin')
if (!builtinConf.loadError) {
const content = ini.stringify(builtinConf.raw).trim() + '\n'
await writeFile(resolve(npmNode.path, 'npmrc'), content)
}
}
}

const builtinConf = npm.config.data.get('builtin')
if (builtinConf.loadError) {
return
}

const content = ini.stringify(builtinConf.raw).trim() + '\n'
await writeFile(resolve(npmNode.path, 'npmrc'), content)
reifyOutput(npm, arb)
}

module.exports = reifyFinish
4 changes: 1 addition & 3 deletions lib/utils/reify-output.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,9 +20,7 @@ const auditError = require('./audit-error.js')
const reifyOutput = (npm, arb) => {
const { diff, actualTree } = arb

// note: fails and crashes if we're running audit fix and there was an error
// which is a good thing, because there's no point printing all this other
// stuff in that case!
// note: fails and crashes if we're running audit fix and there was an error which is a good thing, because there's no point printing all this other stuff in that case!
const auditReport = auditError(npm, arb.auditReport) ? null : arb.auditReport

// don't print any info in --silent mode, but we still need to
Expand Down
29 changes: 25 additions & 4 deletions workspaces/arborist/lib/arborist/isolated-reifier.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ const getKey = (startNode) => {

module.exports = cls => class IsolatedReifier extends cls {
#externalProxies = new Map()
#omit = new Set()
#rootDeclaredDeps = new Set()
#processedEdges = new Set()
#workspaceProxies = new Map()
Expand DownExpand Up@@ -72,15 +73,18 @@ module.exports = cls => class IsolatedReifier extends cls {
**/
async makeIdealGraph () {
const idealTree = this.idealTree
const omit = new Set(this.options.omit)
this.#omit = new Set(this.options.omit)
const omit = this.#omit

// npm auto-creates 'workspace' edges from root to all workspaces.
// For isolated/linked mode, only include workspaces that root explicitly declares as dependencies.
// When omitting dep types, exclude those from the declared set so their workspaces aren't hoisted.
const rootPkg = idealTree.package
this.#rootDeclaredDeps = new Set([
...Object.keys(rootPkg.dependencies || {}),
...Object.keys(rootPkg.devDependencies || {}),
...Object.keys(rootPkg.optionalDependencies || {}),
...(!omit.has('dev') ? Object.keys(rootPkg.devDependencies || {}) : []),
...(!omit.has('optional') ? Object.keys(rootPkg.optionalDependencies || {}) : []),
...(!omit.has('peer') ? Object.keys(rootPkg.peerDependencies || {}) : []),
])

// XXX this sometimes acts like a node too
Expand DownExpand Up@@ -195,10 +199,27 @@ module.exports = cls => class IsolatedReifier extends cls {
return
}

const edges = [...node.edgesOut.values()].filter(edge =>
let edges = [...node.edgesOut.values()].filter(edge =>
edge.to?.target &&
!(node.package.bundledDependencies || node.package.bundleDependencies)?.includes(edge.to.name)
)

// Only omit edge types for root and workspace nodes (matching shouldOmit scope)
if ((node.isProjectRoot || node.isWorkspace) && this.#omit.size) {
edges = edges.filter(edge => {
if (edge.dev && this.#omit.has('dev')) {
return false
}
if (edge.optional && this.#omit.has('optional')) {
return false
}
if (edge.peer && this.#omit.has('peer')) {
return false
}
return true
})
}

let nonOptionalDeps = edges.filter(edge => !edge.optional).map(edge => edge.to.target)

// npm auto-creates 'workspace' edges from root to all workspaces.
Expand Down
133 changes: 133 additions & 0 deletions workspaces/arborist/test/isolated-mode.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -2155,6 +2155,139 @@ tap.test('omit dev dependencies with linked strategy', async t => {
t.notOk(storeEntries.some(e => e.startsWith('eslint@')), 'dev dep eslint is not in store')
})

tap.test('omit dev deps from root even when shared with workspace prod deps', async t => {
// In a monorepo, a root devDependency may also be a workspace prod dependency.
// With --omit=dev, root should NOT link to it, but the workspace still should.
// Also covers the case where a workspace itself is a root devDependency.
const graph = {
registry: [
{ name: 'typescript', version: '5.0.0' },
{ name: 'which', version: '1.0.0', dependencies: { isexe: '^1.0.0' } },
{ name: 'isexe', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0', mylib: '1.0.0' },
devDependencies: { typescript: '5.0.0', devtool: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { typescript: '5.0.0' },
},
{
name: 'devtool',
version: '1.0.0',
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['dev'],
})
await arborist.reify({ installStrategy: 'linked' })

const storeDir = path.join(dir, 'node_modules', '.store')
const storeEntries = fs.readdirSync(storeDir)

// typescript should still be in the store because mylib needs it as a prod dep
t.ok(storeEntries.some(e => e.startsWith('typescript@')), 'typescript is in store (workspace prod dep)')
t.ok(storeEntries.some(e => e.startsWith('which@')), 'which is in store')

// root should NOT have a symlink to typescript (it's a dev dep of root)
const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has symlink to prod dep which')
t.ok(rootNmEntries.includes('mylib'), 'root has symlink to prod workspace mylib')
t.notOk(rootNmEntries.includes('typescript'), 'root does not have symlink to dev dep typescript')
t.notOk(rootNmEntries.includes('devtool'), 'root does not have symlink to dev workspace devtool')

// workspace should have a symlink to typescript (it's a prod dep of mylib)
const wsNmEntries = fs.readdirSync(path.join(dir, 'packages', 'mylib', 'node_modules'))
t.ok(wsNmEntries.includes('typescript'), 'workspace has symlink to prod dep typescript')
})

tap.test('omit optional dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'fsevents', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
optionalDependencies: { fsevents: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { fsevents: '1.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['optional'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('fsevents'), 'root does not have optional dep fsevents')
})

tap.test('omit peer dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'react', version: '18.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
peerDependencies: { react: '18.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { react: '18.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['peer'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('react'), 'root does not have peer dep react')
})

/*
* TO TEST:
* --------------------------------------
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [pull] latest from npm:latest by pull[bot] · Pull Request #150 · LadyK-21/cli · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 16 additions & 18 deletions lib/commands/ci.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,8 @@ class CI extends ArboristWorkspaceCmd {
})
}

const dryRun = this.npm.config.get('dry-run')
const ignoreScripts = this.npm.config.get('ignore-scripts')
const where = this.npm.prefix
const Arborist = require('@npmcli/arborist')
const opts = {
Expand All@@ -46,38 +48,35 @@ class CI extends ArboristWorkspaceCmd {
workspaces: this.workspaceNames,
}

const arb = new Arborist(opts)
await arb.loadVirtual().catch(er => {
log.verbose('loadVirtual', er.stack)
// generate an inventory from the virtual tree in the lockfile
const virtualArb = new Arborist(opts)
try {
await virtualArb.loadVirtual()
} catch (err) {
log.verbose('loadVirtual', err.stack)
const msg =
'The `npm ci` command can only install with an existing package-lock.json or\n' +
'npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or\n' +
'later to generate a package-lock.json file, then try again.'
throw this.usageError(msg)
})

// retrieves inventory of packages from loaded virtual tree (lock file)
const virtualInventory = new Map(arb.virtualTree.inventory)
}
const virtualInventory = new Map(virtualArb.virtualTree.inventory)

// build ideal tree step needs to come right after retrieving the virtual
// inventory since it's going to erase the previous ref to virtualTree
// Now we make our real Arborist.
// We need a new one because the virtual tree fromt the lockfile can have extraneous dependencies in it that won't install on this platform
const arb = new Arborist(opts)
await arb.buildIdealTree()

// verifies that the packages from the ideal tree will match
// the same versions that are present in the virtual tree (lock file)
// throws a validation error in case of mismatches
// Verifies that the packages from the ideal tree will match the same versions that are present in the virtual tree (lock file).
const errors = validateLockfile(virtualInventory, arb.idealTree.inventory)
if (errors.length) {
throw this.usageError(
'`npm ci` can only install packages when your package.json and ' +
'package-lock.json or npm-shrinkwrap.json are in sync. Please ' +
'update your lock file with `npm install` ' +
'before continuing.\n\n' +
'`npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. ' +
'Please update your lock file with `npm install` before continuing.\n\n' +
errors.join('\n')
)
}

const dryRun = this.npm.config.get('dry-run')
if (!dryRun) {
const workspacePaths = await getWorkspaces([], {
path: this.npm.localPrefix,
Expand All@@ -100,7 +99,6 @@ class CI extends ArboristWorkspaceCmd {

await arb.reify(opts)

const ignoreScripts = this.npm.config.get('ignore-scripts')
// run the same set of scripts that `npm install` runs.
if (!ignoreScripts) {
const scripts = [
Expand Down
3 changes: 2 additions & 1 deletion lib/utils/audit-error.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ const { redactLog: replaceInfo } = require('@npmcli/redact')
// returns 'true' if there was an error, false otherwise

const auditError = (npm, report) => {
if (!report || !report.error) {
if (!report?.error) {
return false
}

Expand All@@ -34,6 +34,7 @@ const auditError = (npm, report) => {
output.standard(body)
}

// XXX we should throw a real error here
throw 'audit endpoint returned an error'
}

Expand Down
34 changes: 11 additions & 23 deletions lib/utils/reify-finish.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,30 +4,18 @@ const { writeFile } = require('node:fs/promises')
const { resolve } = require('node:path')

const reifyFinish = async (npm, arb) => {
await saveBuiltinConfig(npm, arb)
reifyOutput(npm, arb)
}

const saveBuiltinConfig = async (npm, arb) => {
const { options: { global }, actualTree } = arb
if (!global) {
return
}

// if we are using a builtin config, and just installed npm as
// a top-level global package, we have to preserve that config.
const npmNode = actualTree.inventory.get('node_modules/npm')
if (!npmNode) {
return
// if we are using a builtin config, and just installed npm as a top-level global package, we have to preserve that config.
if (arb.options.global) {
const npmNode = arb.actualTree.inventory.get('node_modules/npm')
if (npmNode) {
const builtinConf = npm.config.data.get('builtin')
if (!builtinConf.loadError) {
const content = ini.stringify(builtinConf.raw).trim() + '\n'
await writeFile(resolve(npmNode.path, 'npmrc'), content)
}
}
}

const builtinConf = npm.config.data.get('builtin')
if (builtinConf.loadError) {
return
}

const content = ini.stringify(builtinConf.raw).trim() + '\n'
await writeFile(resolve(npmNode.path, 'npmrc'), content)
reifyOutput(npm, arb)
}

module.exports = reifyFinish
4 changes: 1 addition & 3 deletions lib/utils/reify-output.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,9 +20,7 @@ const auditError = require('./audit-error.js')
const reifyOutput = (npm, arb) => {
const { diff, actualTree } = arb

// note: fails and crashes if we're running audit fix and there was an error
// which is a good thing, because there's no point printing all this other
// stuff in that case!
// note: fails and crashes if we're running audit fix and there was an error which is a good thing, because there's no point printing all this other stuff in that case!
const auditReport = auditError(npm, arb.auditReport) ? null : arb.auditReport

// don't print any info in --silent mode, but we still need to
Expand Down
29 changes: 25 additions & 4 deletions workspaces/arborist/lib/arborist/isolated-reifier.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ const getKey = (startNode) => {

module.exports = cls => class IsolatedReifier extends cls {
#externalProxies = new Map()
#omit = new Set()
#rootDeclaredDeps = new Set()
#processedEdges = new Set()
#workspaceProxies = new Map()
Expand DownExpand Up@@ -72,15 +73,18 @@ module.exports = cls => class IsolatedReifier extends cls {
**/
async makeIdealGraph () {
const idealTree = this.idealTree
const omit = new Set(this.options.omit)
this.#omit = new Set(this.options.omit)
const omit = this.#omit

// npm auto-creates 'workspace' edges from root to all workspaces.
// For isolated/linked mode, only include workspaces that root explicitly declares as dependencies.
// When omitting dep types, exclude those from the declared set so their workspaces aren't hoisted.
const rootPkg = idealTree.package
this.#rootDeclaredDeps = new Set([
...Object.keys(rootPkg.dependencies || {}),
...Object.keys(rootPkg.devDependencies || {}),
...Object.keys(rootPkg.optionalDependencies || {}),
...(!omit.has('dev') ? Object.keys(rootPkg.devDependencies || {}) : []),
...(!omit.has('optional') ? Object.keys(rootPkg.optionalDependencies || {}) : []),
...(!omit.has('peer') ? Object.keys(rootPkg.peerDependencies || {}) : []),
])

// XXX this sometimes acts like a node too
Expand DownExpand Up@@ -195,10 +199,27 @@ module.exports = cls => class IsolatedReifier extends cls {
return
}

const edges = [...node.edgesOut.values()].filter(edge =>
let edges = [...node.edgesOut.values()].filter(edge =>
edge.to?.target &&
!(node.package.bundledDependencies || node.package.bundleDependencies)?.includes(edge.to.name)
)

// Only omit edge types for root and workspace nodes (matching shouldOmit scope)
if ((node.isProjectRoot || node.isWorkspace) && this.#omit.size) {
edges = edges.filter(edge => {
if (edge.dev && this.#omit.has('dev')) {
return false
}
if (edge.optional && this.#omit.has('optional')) {
return false
}
if (edge.peer && this.#omit.has('peer')) {
return false
}
return true
})
}

let nonOptionalDeps = edges.filter(edge => !edge.optional).map(edge => edge.to.target)

// npm auto-creates 'workspace' edges from root to all workspaces.
Expand Down
133 changes: 133 additions & 0 deletions workspaces/arborist/test/isolated-mode.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -2155,6 +2155,139 @@ tap.test('omit dev dependencies with linked strategy', async t => {
t.notOk(storeEntries.some(e => e.startsWith('eslint@')), 'dev dep eslint is not in store')
})

tap.test('omit dev deps from root even when shared with workspace prod deps', async t => {
// In a monorepo, a root devDependency may also be a workspace prod dependency.
// With --omit=dev, root should NOT link to it, but the workspace still should.
// Also covers the case where a workspace itself is a root devDependency.
const graph = {
registry: [
{ name: 'typescript', version: '5.0.0' },
{ name: 'which', version: '1.0.0', dependencies: { isexe: '^1.0.0' } },
{ name: 'isexe', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0', mylib: '1.0.0' },
devDependencies: { typescript: '5.0.0', devtool: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { typescript: '5.0.0' },
},
{
name: 'devtool',
version: '1.0.0',
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['dev'],
})
await arborist.reify({ installStrategy: 'linked' })

const storeDir = path.join(dir, 'node_modules', '.store')
const storeEntries = fs.readdirSync(storeDir)

// typescript should still be in the store because mylib needs it as a prod dep
t.ok(storeEntries.some(e => e.startsWith('typescript@')), 'typescript is in store (workspace prod dep)')
t.ok(storeEntries.some(e => e.startsWith('which@')), 'which is in store')

// root should NOT have a symlink to typescript (it's a dev dep of root)
const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has symlink to prod dep which')
t.ok(rootNmEntries.includes('mylib'), 'root has symlink to prod workspace mylib')
t.notOk(rootNmEntries.includes('typescript'), 'root does not have symlink to dev dep typescript')
t.notOk(rootNmEntries.includes('devtool'), 'root does not have symlink to dev workspace devtool')

// workspace should have a symlink to typescript (it's a prod dep of mylib)
const wsNmEntries = fs.readdirSync(path.join(dir, 'packages', 'mylib', 'node_modules'))
t.ok(wsNmEntries.includes('typescript'), 'workspace has symlink to prod dep typescript')
})

tap.test('omit optional dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'fsevents', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
optionalDependencies: { fsevents: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { fsevents: '1.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['optional'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('fsevents'), 'root does not have optional dep fsevents')
})

tap.test('omit peer dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'react', version: '18.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
peerDependencies: { react: '18.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { react: '18.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['peer'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('react'), 'root does not have peer dep react')
})

/*
* TO TEST:
* --------------------------------------
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' [pull] latest from npm:latest by pull[bot] · Pull Request #150 · LadyK-21/cli · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 16 additions & 18 deletions lib/commands/ci.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,8 @@ class CI extends ArboristWorkspaceCmd {
})
}

const dryRun = this.npm.config.get('dry-run')
const ignoreScripts = this.npm.config.get('ignore-scripts')
const where = this.npm.prefix
const Arborist = require('@npmcli/arborist')
const opts = {
Expand All@@ -46,38 +48,35 @@ class CI extends ArboristWorkspaceCmd {
workspaces: this.workspaceNames,
}

const arb = new Arborist(opts)
await arb.loadVirtual().catch(er => {
log.verbose('loadVirtual', er.stack)
// generate an inventory from the virtual tree in the lockfile
const virtualArb = new Arborist(opts)
try {
await virtualArb.loadVirtual()
} catch (err) {
log.verbose('loadVirtual', err.stack)
const msg =
'The `npm ci` command can only install with an existing package-lock.json or\n' +
'npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or\n' +
'later to generate a package-lock.json file, then try again.'
throw this.usageError(msg)
})

// retrieves inventory of packages from loaded virtual tree (lock file)
const virtualInventory = new Map(arb.virtualTree.inventory)
}
const virtualInventory = new Map(virtualArb.virtualTree.inventory)

// build ideal tree step needs to come right after retrieving the virtual
// inventory since it's going to erase the previous ref to virtualTree
// Now we make our real Arborist.
// We need a new one because the virtual tree fromt the lockfile can have extraneous dependencies in it that won't install on this platform
const arb = new Arborist(opts)
await arb.buildIdealTree()

// verifies that the packages from the ideal tree will match
// the same versions that are present in the virtual tree (lock file)
// throws a validation error in case of mismatches
// Verifies that the packages from the ideal tree will match the same versions that are present in the virtual tree (lock file).
const errors = validateLockfile(virtualInventory, arb.idealTree.inventory)
if (errors.length) {
throw this.usageError(
'`npm ci` can only install packages when your package.json and ' +
'package-lock.json or npm-shrinkwrap.json are in sync. Please ' +
'update your lock file with `npm install` ' +
'before continuing.\n\n' +
'`npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. ' +
'Please update your lock file with `npm install` before continuing.\n\n' +
errors.join('\n')
)
}

const dryRun = this.npm.config.get('dry-run')
if (!dryRun) {
const workspacePaths = await getWorkspaces([], {
path: this.npm.localPrefix,
Expand All@@ -100,7 +99,6 @@ class CI extends ArboristWorkspaceCmd {

await arb.reify(opts)

const ignoreScripts = this.npm.config.get('ignore-scripts')
// run the same set of scripts that `npm install` runs.
if (!ignoreScripts) {
const scripts = [
Expand Down
3 changes: 2 additions & 1 deletion lib/utils/audit-error.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ const { redactLog: replaceInfo } = require('@npmcli/redact')
// returns 'true' if there was an error, false otherwise

const auditError = (npm, report) => {
if (!report || !report.error) {
if (!report?.error) {
return false
}

Expand All@@ -34,6 +34,7 @@ const auditError = (npm, report) => {
output.standard(body)
}

// XXX we should throw a real error here
throw 'audit endpoint returned an error'
}

Expand Down
34 changes: 11 additions & 23 deletions lib/utils/reify-finish.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,30 +4,18 @@ const { writeFile } = require('node:fs/promises')
const { resolve } = require('node:path')

const reifyFinish = async (npm, arb) => {
await saveBuiltinConfig(npm, arb)
reifyOutput(npm, arb)
}

const saveBuiltinConfig = async (npm, arb) => {
const { options: { global }, actualTree } = arb
if (!global) {
return
}

// if we are using a builtin config, and just installed npm as
// a top-level global package, we have to preserve that config.
const npmNode = actualTree.inventory.get('node_modules/npm')
if (!npmNode) {
return
// if we are using a builtin config, and just installed npm as a top-level global package, we have to preserve that config.
if (arb.options.global) {
const npmNode = arb.actualTree.inventory.get('node_modules/npm')
if (npmNode) {
const builtinConf = npm.config.data.get('builtin')
if (!builtinConf.loadError) {
const content = ini.stringify(builtinConf.raw).trim() + '\n'
await writeFile(resolve(npmNode.path, 'npmrc'), content)
}
}
}

const builtinConf = npm.config.data.get('builtin')
if (builtinConf.loadError) {
return
}

const content = ini.stringify(builtinConf.raw).trim() + '\n'
await writeFile(resolve(npmNode.path, 'npmrc'), content)
reifyOutput(npm, arb)
}

module.exports = reifyFinish
4 changes: 1 addition & 3 deletions lib/utils/reify-output.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,9 +20,7 @@ const auditError = require('./audit-error.js')
const reifyOutput = (npm, arb) => {
const { diff, actualTree } = arb

// note: fails and crashes if we're running audit fix and there was an error
// which is a good thing, because there's no point printing all this other
// stuff in that case!
// note: fails and crashes if we're running audit fix and there was an error which is a good thing, because there's no point printing all this other stuff in that case!
const auditReport = auditError(npm, arb.auditReport) ? null : arb.auditReport

// don't print any info in --silent mode, but we still need to
Expand Down
29 changes: 25 additions & 4 deletions workspaces/arborist/lib/arborist/isolated-reifier.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ const getKey = (startNode) => {

module.exports = cls => class IsolatedReifier extends cls {
#externalProxies = new Map()
#omit = new Set()
#rootDeclaredDeps = new Set()
#processedEdges = new Set()
#workspaceProxies = new Map()
Expand DownExpand Up@@ -72,15 +73,18 @@ module.exports = cls => class IsolatedReifier extends cls {
**/
async makeIdealGraph () {
const idealTree = this.idealTree
const omit = new Set(this.options.omit)
this.#omit = new Set(this.options.omit)
const omit = this.#omit

// npm auto-creates 'workspace' edges from root to all workspaces.
// For isolated/linked mode, only include workspaces that root explicitly declares as dependencies.
// When omitting dep types, exclude those from the declared set so their workspaces aren't hoisted.
const rootPkg = idealTree.package
this.#rootDeclaredDeps = new Set([
...Object.keys(rootPkg.dependencies || {}),
...Object.keys(rootPkg.devDependencies || {}),
...Object.keys(rootPkg.optionalDependencies || {}),
...(!omit.has('dev') ? Object.keys(rootPkg.devDependencies || {}) : []),
...(!omit.has('optional') ? Object.keys(rootPkg.optionalDependencies || {}) : []),
...(!omit.has('peer') ? Object.keys(rootPkg.peerDependencies || {}) : []),
])

// XXX this sometimes acts like a node too
Expand DownExpand Up@@ -195,10 +199,27 @@ module.exports = cls => class IsolatedReifier extends cls {
return
}

const edges = [...node.edgesOut.values()].filter(edge =>
let edges = [...node.edgesOut.values()].filter(edge =>
edge.to?.target &&
!(node.package.bundledDependencies || node.package.bundleDependencies)?.includes(edge.to.name)
)

// Only omit edge types for root and workspace nodes (matching shouldOmit scope)
if ((node.isProjectRoot || node.isWorkspace) && this.#omit.size) {
edges = edges.filter(edge => {
if (edge.dev && this.#omit.has('dev')) {
return false
}
if (edge.optional && this.#omit.has('optional')) {
return false
}
if (edge.peer && this.#omit.has('peer')) {
return false
}
return true
})
}

let nonOptionalDeps = edges.filter(edge => !edge.optional).map(edge => edge.to.target)

// npm auto-creates 'workspace' edges from root to all workspaces.
Expand Down
133 changes: 133 additions & 0 deletions workspaces/arborist/test/isolated-mode.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -2155,6 +2155,139 @@ tap.test('omit dev dependencies with linked strategy', async t => {
t.notOk(storeEntries.some(e => e.startsWith('eslint@')), 'dev dep eslint is not in store')
})

tap.test('omit dev deps from root even when shared with workspace prod deps', async t => {
// In a monorepo, a root devDependency may also be a workspace prod dependency.
// With --omit=dev, root should NOT link to it, but the workspace still should.
// Also covers the case where a workspace itself is a root devDependency.
const graph = {
registry: [
{ name: 'typescript', version: '5.0.0' },
{ name: 'which', version: '1.0.0', dependencies: { isexe: '^1.0.0' } },
{ name: 'isexe', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0', mylib: '1.0.0' },
devDependencies: { typescript: '5.0.0', devtool: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { typescript: '5.0.0' },
},
{
name: 'devtool',
version: '1.0.0',
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['dev'],
})
await arborist.reify({ installStrategy: 'linked' })

const storeDir = path.join(dir, 'node_modules', '.store')
const storeEntries = fs.readdirSync(storeDir)

// typescript should still be in the store because mylib needs it as a prod dep
t.ok(storeEntries.some(e => e.startsWith('typescript@')), 'typescript is in store (workspace prod dep)')
t.ok(storeEntries.some(e => e.startsWith('which@')), 'which is in store')

// root should NOT have a symlink to typescript (it's a dev dep of root)
const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has symlink to prod dep which')
t.ok(rootNmEntries.includes('mylib'), 'root has symlink to prod workspace mylib')
t.notOk(rootNmEntries.includes('typescript'), 'root does not have symlink to dev dep typescript')
t.notOk(rootNmEntries.includes('devtool'), 'root does not have symlink to dev workspace devtool')

// workspace should have a symlink to typescript (it's a prod dep of mylib)
const wsNmEntries = fs.readdirSync(path.join(dir, 'packages', 'mylib', 'node_modules'))
t.ok(wsNmEntries.includes('typescript'), 'workspace has symlink to prod dep typescript')
})

tap.test('omit optional dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'fsevents', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
optionalDependencies: { fsevents: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { fsevents: '1.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['optional'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('fsevents'), 'root does not have optional dep fsevents')
})

tap.test('omit peer dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'react', version: '18.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
peerDependencies: { react: '18.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { react: '18.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['peer'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('react'), 'root does not have peer dep react')
})

/*
* TO TEST:
* --------------------------------------
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [pull] latest from npm:latest by pull[bot] · Pull Request #150 · LadyK-21/cli · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 16 additions & 18 deletions lib/commands/ci.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,8 @@ class CI extends ArboristWorkspaceCmd {
})
}

const dryRun = this.npm.config.get('dry-run')
const ignoreScripts = this.npm.config.get('ignore-scripts')
const where = this.npm.prefix
const Arborist = require('@npmcli/arborist')
const opts = {
Expand All@@ -46,38 +48,35 @@ class CI extends ArboristWorkspaceCmd {
workspaces: this.workspaceNames,
}

const arb = new Arborist(opts)
await arb.loadVirtual().catch(er => {
log.verbose('loadVirtual', er.stack)
// generate an inventory from the virtual tree in the lockfile
const virtualArb = new Arborist(opts)
try {
await virtualArb.loadVirtual()
} catch (err) {
log.verbose('loadVirtual', err.stack)
const msg =
'The `npm ci` command can only install with an existing package-lock.json or\n' +
'npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or\n' +
'later to generate a package-lock.json file, then try again.'
throw this.usageError(msg)
})

// retrieves inventory of packages from loaded virtual tree (lock file)
const virtualInventory = new Map(arb.virtualTree.inventory)
}
const virtualInventory = new Map(virtualArb.virtualTree.inventory)

// build ideal tree step needs to come right after retrieving the virtual
// inventory since it's going to erase the previous ref to virtualTree
// Now we make our real Arborist.
// We need a new one because the virtual tree fromt the lockfile can have extraneous dependencies in it that won't install on this platform
const arb = new Arborist(opts)
await arb.buildIdealTree()

// verifies that the packages from the ideal tree will match
// the same versions that are present in the virtual tree (lock file)
// throws a validation error in case of mismatches
// Verifies that the packages from the ideal tree will match the same versions that are present in the virtual tree (lock file).
const errors = validateLockfile(virtualInventory, arb.idealTree.inventory)
if (errors.length) {
throw this.usageError(
'`npm ci` can only install packages when your package.json and ' +
'package-lock.json or npm-shrinkwrap.json are in sync. Please ' +
'update your lock file with `npm install` ' +
'before continuing.\n\n' +
'`npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. ' +
'Please update your lock file with `npm install` before continuing.\n\n' +
errors.join('\n')
)
}

const dryRun = this.npm.config.get('dry-run')
if (!dryRun) {
const workspacePaths = await getWorkspaces([], {
path: this.npm.localPrefix,
Expand All@@ -100,7 +99,6 @@ class CI extends ArboristWorkspaceCmd {

await arb.reify(opts)

const ignoreScripts = this.npm.config.get('ignore-scripts')
// run the same set of scripts that `npm install` runs.
if (!ignoreScripts) {
const scripts = [
Expand Down
3 changes: 2 additions & 1 deletion lib/utils/audit-error.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ const { redactLog: replaceInfo } = require('@npmcli/redact')
// returns 'true' if there was an error, false otherwise

const auditError = (npm, report) => {
if (!report || !report.error) {
if (!report?.error) {
return false
}

Expand All@@ -34,6 +34,7 @@ const auditError = (npm, report) => {
output.standard(body)
}

// XXX we should throw a real error here
throw 'audit endpoint returned an error'
}

Expand Down
34 changes: 11 additions & 23 deletions lib/utils/reify-finish.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,30 +4,18 @@ const { writeFile } = require('node:fs/promises')
const { resolve } = require('node:path')

const reifyFinish = async (npm, arb) => {
await saveBuiltinConfig(npm, arb)
reifyOutput(npm, arb)
}

const saveBuiltinConfig = async (npm, arb) => {
const { options: { global }, actualTree } = arb
if (!global) {
return
}

// if we are using a builtin config, and just installed npm as
// a top-level global package, we have to preserve that config.
const npmNode = actualTree.inventory.get('node_modules/npm')
if (!npmNode) {
return
// if we are using a builtin config, and just installed npm as a top-level global package, we have to preserve that config.
if (arb.options.global) {
const npmNode = arb.actualTree.inventory.get('node_modules/npm')
if (npmNode) {
const builtinConf = npm.config.data.get('builtin')
if (!builtinConf.loadError) {
const content = ini.stringify(builtinConf.raw).trim() + '\n'
await writeFile(resolve(npmNode.path, 'npmrc'), content)
}
}
}

const builtinConf = npm.config.data.get('builtin')
if (builtinConf.loadError) {
return
}

const content = ini.stringify(builtinConf.raw).trim() + '\n'
await writeFile(resolve(npmNode.path, 'npmrc'), content)
reifyOutput(npm, arb)
}

module.exports = reifyFinish
4 changes: 1 addition & 3 deletions lib/utils/reify-output.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,9 +20,7 @@ const auditError = require('./audit-error.js')
const reifyOutput = (npm, arb) => {
const { diff, actualTree } = arb

// note: fails and crashes if we're running audit fix and there was an error
// which is a good thing, because there's no point printing all this other
// stuff in that case!
// note: fails and crashes if we're running audit fix and there was an error which is a good thing, because there's no point printing all this other stuff in that case!
const auditReport = auditError(npm, arb.auditReport) ? null : arb.auditReport

// don't print any info in --silent mode, but we still need to
Expand Down
29 changes: 25 additions & 4 deletions workspaces/arborist/lib/arborist/isolated-reifier.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ const getKey = (startNode) => {

module.exports = cls => class IsolatedReifier extends cls {
#externalProxies = new Map()
#omit = new Set()
#rootDeclaredDeps = new Set()
#processedEdges = new Set()
#workspaceProxies = new Map()
Expand DownExpand Up@@ -72,15 +73,18 @@ module.exports = cls => class IsolatedReifier extends cls {
**/
async makeIdealGraph () {
const idealTree = this.idealTree
const omit = new Set(this.options.omit)
this.#omit = new Set(this.options.omit)
const omit = this.#omit

// npm auto-creates 'workspace' edges from root to all workspaces.
// For isolated/linked mode, only include workspaces that root explicitly declares as dependencies.
// When omitting dep types, exclude those from the declared set so their workspaces aren't hoisted.
const rootPkg = idealTree.package
this.#rootDeclaredDeps = new Set([
...Object.keys(rootPkg.dependencies || {}),
...Object.keys(rootPkg.devDependencies || {}),
...Object.keys(rootPkg.optionalDependencies || {}),
...(!omit.has('dev') ? Object.keys(rootPkg.devDependencies || {}) : []),
...(!omit.has('optional') ? Object.keys(rootPkg.optionalDependencies || {}) : []),
...(!omit.has('peer') ? Object.keys(rootPkg.peerDependencies || {}) : []),
])

// XXX this sometimes acts like a node too
Expand DownExpand Up@@ -195,10 +199,27 @@ module.exports = cls => class IsolatedReifier extends cls {
return
}

const edges = [...node.edgesOut.values()].filter(edge =>
let edges = [...node.edgesOut.values()].filter(edge =>
edge.to?.target &&
!(node.package.bundledDependencies || node.package.bundleDependencies)?.includes(edge.to.name)
)

// Only omit edge types for root and workspace nodes (matching shouldOmit scope)
if ((node.isProjectRoot || node.isWorkspace) && this.#omit.size) {
edges = edges.filter(edge => {
if (edge.dev && this.#omit.has('dev')) {
return false
}
if (edge.optional && this.#omit.has('optional')) {
return false
}
if (edge.peer && this.#omit.has('peer')) {
return false
}
return true
})
}

let nonOptionalDeps = edges.filter(edge => !edge.optional).map(edge => edge.to.target)

// npm auto-creates 'workspace' edges from root to all workspaces.
Expand Down
133 changes: 133 additions & 0 deletions workspaces/arborist/test/isolated-mode.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -2155,6 +2155,139 @@ tap.test('omit dev dependencies with linked strategy', async t => {
t.notOk(storeEntries.some(e => e.startsWith('eslint@')), 'dev dep eslint is not in store')
})

tap.test('omit dev deps from root even when shared with workspace prod deps', async t => {
// In a monorepo, a root devDependency may also be a workspace prod dependency.
// With --omit=dev, root should NOT link to it, but the workspace still should.
// Also covers the case where a workspace itself is a root devDependency.
const graph = {
registry: [
{ name: 'typescript', version: '5.0.0' },
{ name: 'which', version: '1.0.0', dependencies: { isexe: '^1.0.0' } },
{ name: 'isexe', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0', mylib: '1.0.0' },
devDependencies: { typescript: '5.0.0', devtool: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { typescript: '5.0.0' },
},
{
name: 'devtool',
version: '1.0.0',
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['dev'],
})
await arborist.reify({ installStrategy: 'linked' })

const storeDir = path.join(dir, 'node_modules', '.store')
const storeEntries = fs.readdirSync(storeDir)

// typescript should still be in the store because mylib needs it as a prod dep
t.ok(storeEntries.some(e => e.startsWith('typescript@')), 'typescript is in store (workspace prod dep)')
t.ok(storeEntries.some(e => e.startsWith('which@')), 'which is in store')

// root should NOT have a symlink to typescript (it's a dev dep of root)
const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has symlink to prod dep which')
t.ok(rootNmEntries.includes('mylib'), 'root has symlink to prod workspace mylib')
t.notOk(rootNmEntries.includes('typescript'), 'root does not have symlink to dev dep typescript')
t.notOk(rootNmEntries.includes('devtool'), 'root does not have symlink to dev workspace devtool')

// workspace should have a symlink to typescript (it's a prod dep of mylib)
const wsNmEntries = fs.readdirSync(path.join(dir, 'packages', 'mylib', 'node_modules'))
t.ok(wsNmEntries.includes('typescript'), 'workspace has symlink to prod dep typescript')
})

tap.test('omit optional dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'fsevents', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
optionalDependencies: { fsevents: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { fsevents: '1.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['optional'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('fsevents'), 'root does not have optional dep fsevents')
})

tap.test('omit peer dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'react', version: '18.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
peerDependencies: { react: '18.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { react: '18.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['peer'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('react'), 'root does not have peer dep react')
})

/*
* TO TEST:
* --------------------------------------
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' [pull] latest from npm:latest by pull[bot] · Pull Request #150 · LadyK-21/cli · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 16 additions & 18 deletions lib/commands/ci.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,8 @@ class CI extends ArboristWorkspaceCmd {
})
}

const dryRun = this.npm.config.get('dry-run')
const ignoreScripts = this.npm.config.get('ignore-scripts')
const where = this.npm.prefix
const Arborist = require('@npmcli/arborist')
const opts = {
Expand All@@ -46,38 +48,35 @@ class CI extends ArboristWorkspaceCmd {
workspaces: this.workspaceNames,
}

const arb = new Arborist(opts)
await arb.loadVirtual().catch(er => {
log.verbose('loadVirtual', er.stack)
// generate an inventory from the virtual tree in the lockfile
const virtualArb = new Arborist(opts)
try {
await virtualArb.loadVirtual()
} catch (err) {
log.verbose('loadVirtual', err.stack)
const msg =
'The `npm ci` command can only install with an existing package-lock.json or\n' +
'npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or\n' +
'later to generate a package-lock.json file, then try again.'
throw this.usageError(msg)
})

// retrieves inventory of packages from loaded virtual tree (lock file)
const virtualInventory = new Map(arb.virtualTree.inventory)
}
const virtualInventory = new Map(virtualArb.virtualTree.inventory)

// build ideal tree step needs to come right after retrieving the virtual
// inventory since it's going to erase the previous ref to virtualTree
// Now we make our real Arborist.
// We need a new one because the virtual tree fromt the lockfile can have extraneous dependencies in it that won't install on this platform
const arb = new Arborist(opts)
await arb.buildIdealTree()

// verifies that the packages from the ideal tree will match
// the same versions that are present in the virtual tree (lock file)
// throws a validation error in case of mismatches
// Verifies that the packages from the ideal tree will match the same versions that are present in the virtual tree (lock file).
const errors = validateLockfile(virtualInventory, arb.idealTree.inventory)
if (errors.length) {
throw this.usageError(
'`npm ci` can only install packages when your package.json and ' +
'package-lock.json or npm-shrinkwrap.json are in sync. Please ' +
'update your lock file with `npm install` ' +
'before continuing.\n\n' +
'`npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. ' +
'Please update your lock file with `npm install` before continuing.\n\n' +
errors.join('\n')
)
}

const dryRun = this.npm.config.get('dry-run')
if (!dryRun) {
const workspacePaths = await getWorkspaces([], {
path: this.npm.localPrefix,
Expand All@@ -100,7 +99,6 @@ class CI extends ArboristWorkspaceCmd {

await arb.reify(opts)

const ignoreScripts = this.npm.config.get('ignore-scripts')
// run the same set of scripts that `npm install` runs.
if (!ignoreScripts) {
const scripts = [
Expand Down
3 changes: 2 additions & 1 deletion lib/utils/audit-error.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ const { redactLog: replaceInfo } = require('@npmcli/redact')
// returns 'true' if there was an error, false otherwise

const auditError = (npm, report) => {
if (!report || !report.error) {
if (!report?.error) {
return false
}

Expand All@@ -34,6 +34,7 @@ const auditError = (npm, report) => {
output.standard(body)
}

// XXX we should throw a real error here
throw 'audit endpoint returned an error'
}

Expand Down
34 changes: 11 additions & 23 deletions lib/utils/reify-finish.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,30 +4,18 @@ const { writeFile } = require('node:fs/promises')
const { resolve } = require('node:path')

const reifyFinish = async (npm, arb) => {
await saveBuiltinConfig(npm, arb)
reifyOutput(npm, arb)
}

const saveBuiltinConfig = async (npm, arb) => {
const { options: { global }, actualTree } = arb
if (!global) {
return
}

// if we are using a builtin config, and just installed npm as
// a top-level global package, we have to preserve that config.
const npmNode = actualTree.inventory.get('node_modules/npm')
if (!npmNode) {
return
// if we are using a builtin config, and just installed npm as a top-level global package, we have to preserve that config.
if (arb.options.global) {
const npmNode = arb.actualTree.inventory.get('node_modules/npm')
if (npmNode) {
const builtinConf = npm.config.data.get('builtin')
if (!builtinConf.loadError) {
const content = ini.stringify(builtinConf.raw).trim() + '\n'
await writeFile(resolve(npmNode.path, 'npmrc'), content)
}
}
}

const builtinConf = npm.config.data.get('builtin')
if (builtinConf.loadError) {
return
}

const content = ini.stringify(builtinConf.raw).trim() + '\n'
await writeFile(resolve(npmNode.path, 'npmrc'), content)
reifyOutput(npm, arb)
}

module.exports = reifyFinish
4 changes: 1 addition & 3 deletions lib/utils/reify-output.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,9 +20,7 @@ const auditError = require('./audit-error.js')
const reifyOutput = (npm, arb) => {
const { diff, actualTree } = arb

// note: fails and crashes if we're running audit fix and there was an error
// which is a good thing, because there's no point printing all this other
// stuff in that case!
// note: fails and crashes if we're running audit fix and there was an error which is a good thing, because there's no point printing all this other stuff in that case!
const auditReport = auditError(npm, arb.auditReport) ? null : arb.auditReport

// don't print any info in --silent mode, but we still need to
Expand Down
29 changes: 25 additions & 4 deletions workspaces/arborist/lib/arborist/isolated-reifier.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ const getKey = (startNode) => {

module.exports = cls => class IsolatedReifier extends cls {
#externalProxies = new Map()
#omit = new Set()
#rootDeclaredDeps = new Set()
#processedEdges = new Set()
#workspaceProxies = new Map()
Expand DownExpand Up@@ -72,15 +73,18 @@ module.exports = cls => class IsolatedReifier extends cls {
**/
async makeIdealGraph () {
const idealTree = this.idealTree
const omit = new Set(this.options.omit)
this.#omit = new Set(this.options.omit)
const omit = this.#omit

// npm auto-creates 'workspace' edges from root to all workspaces.
// For isolated/linked mode, only include workspaces that root explicitly declares as dependencies.
// When omitting dep types, exclude those from the declared set so their workspaces aren't hoisted.
const rootPkg = idealTree.package
this.#rootDeclaredDeps = new Set([
...Object.keys(rootPkg.dependencies || {}),
...Object.keys(rootPkg.devDependencies || {}),
...Object.keys(rootPkg.optionalDependencies || {}),
...(!omit.has('dev') ? Object.keys(rootPkg.devDependencies || {}) : []),
...(!omit.has('optional') ? Object.keys(rootPkg.optionalDependencies || {}) : []),
...(!omit.has('peer') ? Object.keys(rootPkg.peerDependencies || {}) : []),
])

// XXX this sometimes acts like a node too
Expand DownExpand Up@@ -195,10 +199,27 @@ module.exports = cls => class IsolatedReifier extends cls {
return
}

const edges = [...node.edgesOut.values()].filter(edge =>
let edges = [...node.edgesOut.values()].filter(edge =>
edge.to?.target &&
!(node.package.bundledDependencies || node.package.bundleDependencies)?.includes(edge.to.name)
)

// Only omit edge types for root and workspace nodes (matching shouldOmit scope)
if ((node.isProjectRoot || node.isWorkspace) && this.#omit.size) {
edges = edges.filter(edge => {
if (edge.dev && this.#omit.has('dev')) {
return false
}
if (edge.optional && this.#omit.has('optional')) {
return false
}
if (edge.peer && this.#omit.has('peer')) {
return false
}
return true
})
}

let nonOptionalDeps = edges.filter(edge => !edge.optional).map(edge => edge.to.target)

// npm auto-creates 'workspace' edges from root to all workspaces.
Expand Down
133 changes: 133 additions & 0 deletions workspaces/arborist/test/isolated-mode.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -2155,6 +2155,139 @@ tap.test('omit dev dependencies with linked strategy', async t => {
t.notOk(storeEntries.some(e => e.startsWith('eslint@')), 'dev dep eslint is not in store')
})

tap.test('omit dev deps from root even when shared with workspace prod deps', async t => {
// In a monorepo, a root devDependency may also be a workspace prod dependency.
// With --omit=dev, root should NOT link to it, but the workspace still should.
// Also covers the case where a workspace itself is a root devDependency.
const graph = {
registry: [
{ name: 'typescript', version: '5.0.0' },
{ name: 'which', version: '1.0.0', dependencies: { isexe: '^1.0.0' } },
{ name: 'isexe', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0', mylib: '1.0.0' },
devDependencies: { typescript: '5.0.0', devtool: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { typescript: '5.0.0' },
},
{
name: 'devtool',
version: '1.0.0',
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['dev'],
})
await arborist.reify({ installStrategy: 'linked' })

const storeDir = path.join(dir, 'node_modules', '.store')
const storeEntries = fs.readdirSync(storeDir)

// typescript should still be in the store because mylib needs it as a prod dep
t.ok(storeEntries.some(e => e.startsWith('typescript@')), 'typescript is in store (workspace prod dep)')
t.ok(storeEntries.some(e => e.startsWith('which@')), 'which is in store')

// root should NOT have a symlink to typescript (it's a dev dep of root)
const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has symlink to prod dep which')
t.ok(rootNmEntries.includes('mylib'), 'root has symlink to prod workspace mylib')
t.notOk(rootNmEntries.includes('typescript'), 'root does not have symlink to dev dep typescript')
t.notOk(rootNmEntries.includes('devtool'), 'root does not have symlink to dev workspace devtool')

// workspace should have a symlink to typescript (it's a prod dep of mylib)
const wsNmEntries = fs.readdirSync(path.join(dir, 'packages', 'mylib', 'node_modules'))
t.ok(wsNmEntries.includes('typescript'), 'workspace has symlink to prod dep typescript')
})

tap.test('omit optional dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'fsevents', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
optionalDependencies: { fsevents: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { fsevents: '1.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['optional'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('fsevents'), 'root does not have optional dep fsevents')
})

tap.test('omit peer dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'react', version: '18.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
peerDependencies: { react: '18.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { react: '18.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['peer'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('react'), 'root does not have peer dep react')
})

/*
* TO TEST:
* --------------------------------------
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); [pull] latest from npm:latest by pull[bot] · Pull Request #150 · LadyK-21/cli · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 16 additions & 18 deletions lib/commands/ci.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,6 +36,8 @@ class CI extends ArboristWorkspaceCmd {
})
}

const dryRun = this.npm.config.get('dry-run')
const ignoreScripts = this.npm.config.get('ignore-scripts')
const where = this.npm.prefix
const Arborist = require('@npmcli/arborist')
const opts = {
Expand All@@ -46,38 +48,35 @@ class CI extends ArboristWorkspaceCmd {
workspaces: this.workspaceNames,
}

const arb = new Arborist(opts)
await arb.loadVirtual().catch(er => {
log.verbose('loadVirtual', er.stack)
// generate an inventory from the virtual tree in the lockfile
const virtualArb = new Arborist(opts)
try {
await virtualArb.loadVirtual()
} catch (err) {
log.verbose('loadVirtual', err.stack)
const msg =
'The `npm ci` command can only install with an existing package-lock.json or\n' +
'npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or\n' +
'later to generate a package-lock.json file, then try again.'
throw this.usageError(msg)
})

// retrieves inventory of packages from loaded virtual tree (lock file)
const virtualInventory = new Map(arb.virtualTree.inventory)
}
const virtualInventory = new Map(virtualArb.virtualTree.inventory)

// build ideal tree step needs to come right after retrieving the virtual
// inventory since it's going to erase the previous ref to virtualTree
// Now we make our real Arborist.
// We need a new one because the virtual tree fromt the lockfile can have extraneous dependencies in it that won't install on this platform
const arb = new Arborist(opts)
await arb.buildIdealTree()

// verifies that the packages from the ideal tree will match
// the same versions that are present in the virtual tree (lock file)
// throws a validation error in case of mismatches
// Verifies that the packages from the ideal tree will match the same versions that are present in the virtual tree (lock file).
const errors = validateLockfile(virtualInventory, arb.idealTree.inventory)
if (errors.length) {
throw this.usageError(
'`npm ci` can only install packages when your package.json and ' +
'package-lock.json or npm-shrinkwrap.json are in sync. Please ' +
'update your lock file with `npm install` ' +
'before continuing.\n\n' +
'`npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. ' +
'Please update your lock file with `npm install` before continuing.\n\n' +
errors.join('\n')
)
}

const dryRun = this.npm.config.get('dry-run')
if (!dryRun) {
const workspacePaths = await getWorkspaces([], {
path: this.npm.localPrefix,
Expand All@@ -100,7 +99,6 @@ class CI extends ArboristWorkspaceCmd {

await arb.reify(opts)

const ignoreScripts = this.npm.config.get('ignore-scripts')
// run the same set of scripts that `npm install` runs.
if (!ignoreScripts) {
const scripts = [
Expand Down
3 changes: 2 additions & 1 deletion lib/utils/audit-error.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,7 +7,7 @@ const { redactLog: replaceInfo } = require('@npmcli/redact')
// returns 'true' if there was an error, false otherwise

const auditError = (npm, report) => {
if (!report || !report.error) {
if (!report?.error) {
return false
}

Expand All@@ -34,6 +34,7 @@ const auditError = (npm, report) => {
output.standard(body)
}

// XXX we should throw a real error here
throw 'audit endpoint returned an error'
}

Expand Down
34 changes: 11 additions & 23 deletions lib/utils/reify-finish.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,30 +4,18 @@ const { writeFile } = require('node:fs/promises')
const { resolve } = require('node:path')

const reifyFinish = async (npm, arb) => {
await saveBuiltinConfig(npm, arb)
reifyOutput(npm, arb)
}

const saveBuiltinConfig = async (npm, arb) => {
const { options: { global }, actualTree } = arb
if (!global) {
return
}

// if we are using a builtin config, and just installed npm as
// a top-level global package, we have to preserve that config.
const npmNode = actualTree.inventory.get('node_modules/npm')
if (!npmNode) {
return
// if we are using a builtin config, and just installed npm as a top-level global package, we have to preserve that config.
if (arb.options.global) {
const npmNode = arb.actualTree.inventory.get('node_modules/npm')
if (npmNode) {
const builtinConf = npm.config.data.get('builtin')
if (!builtinConf.loadError) {
const content = ini.stringify(builtinConf.raw).trim() + '\n'
await writeFile(resolve(npmNode.path, 'npmrc'), content)
}
}
}

const builtinConf = npm.config.data.get('builtin')
if (builtinConf.loadError) {
return
}

const content = ini.stringify(builtinConf.raw).trim() + '\n'
await writeFile(resolve(npmNode.path, 'npmrc'), content)
reifyOutput(npm, arb)
}

module.exports = reifyFinish
4 changes: 1 addition & 3 deletions lib/utils/reify-output.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,9 +20,7 @@ const auditError = require('./audit-error.js')
const reifyOutput = (npm, arb) => {
const { diff, actualTree } = arb

// note: fails and crashes if we're running audit fix and there was an error
// which is a good thing, because there's no point printing all this other
// stuff in that case!
// note: fails and crashes if we're running audit fix and there was an error which is a good thing, because there's no point printing all this other stuff in that case!
const auditReport = auditError(npm, arb.auditReport) ? null : arb.auditReport

// don't print any info in --silent mode, but we still need to
Expand Down
29 changes: 25 additions & 4 deletions workspaces/arborist/lib/arborist/isolated-reifier.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -34,6 +34,7 @@ const getKey = (startNode) => {

module.exports = cls => class IsolatedReifier extends cls {
#externalProxies = new Map()
#omit = new Set()
#rootDeclaredDeps = new Set()
#processedEdges = new Set()
#workspaceProxies = new Map()
Expand DownExpand Up@@ -72,15 +73,18 @@ module.exports = cls => class IsolatedReifier extends cls {
**/
async makeIdealGraph () {
const idealTree = this.idealTree
const omit = new Set(this.options.omit)
this.#omit = new Set(this.options.omit)
const omit = this.#omit

// npm auto-creates 'workspace' edges from root to all workspaces.
// For isolated/linked mode, only include workspaces that root explicitly declares as dependencies.
// When omitting dep types, exclude those from the declared set so their workspaces aren't hoisted.
const rootPkg = idealTree.package
this.#rootDeclaredDeps = new Set([
...Object.keys(rootPkg.dependencies || {}),
...Object.keys(rootPkg.devDependencies || {}),
...Object.keys(rootPkg.optionalDependencies || {}),
...(!omit.has('dev') ? Object.keys(rootPkg.devDependencies || {}) : []),
...(!omit.has('optional') ? Object.keys(rootPkg.optionalDependencies || {}) : []),
...(!omit.has('peer') ? Object.keys(rootPkg.peerDependencies || {}) : []),
])

// XXX this sometimes acts like a node too
Expand DownExpand Up@@ -195,10 +199,27 @@ module.exports = cls => class IsolatedReifier extends cls {
return
}

const edges = [...node.edgesOut.values()].filter(edge =>
let edges = [...node.edgesOut.values()].filter(edge =>
edge.to?.target &&
!(node.package.bundledDependencies || node.package.bundleDependencies)?.includes(edge.to.name)
)

// Only omit edge types for root and workspace nodes (matching shouldOmit scope)
if ((node.isProjectRoot || node.isWorkspace) && this.#omit.size) {
edges = edges.filter(edge => {
if (edge.dev && this.#omit.has('dev')) {
return false
}
if (edge.optional && this.#omit.has('optional')) {
return false
}
if (edge.peer && this.#omit.has('peer')) {
return false
}
return true
})
}

let nonOptionalDeps = edges.filter(edge => !edge.optional).map(edge => edge.to.target)

// npm auto-creates 'workspace' edges from root to all workspaces.
Expand Down
133 changes: 133 additions & 0 deletions workspaces/arborist/test/isolated-mode.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -2155,6 +2155,139 @@ tap.test('omit dev dependencies with linked strategy', async t => {
t.notOk(storeEntries.some(e => e.startsWith('eslint@')), 'dev dep eslint is not in store')
})

tap.test('omit dev deps from root even when shared with workspace prod deps', async t => {
// In a monorepo, a root devDependency may also be a workspace prod dependency.
// With --omit=dev, root should NOT link to it, but the workspace still should.
// Also covers the case where a workspace itself is a root devDependency.
const graph = {
registry: [
{ name: 'typescript', version: '5.0.0' },
{ name: 'which', version: '1.0.0', dependencies: { isexe: '^1.0.0' } },
{ name: 'isexe', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0', mylib: '1.0.0' },
devDependencies: { typescript: '5.0.0', devtool: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { typescript: '5.0.0' },
},
{
name: 'devtool',
version: '1.0.0',
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['dev'],
})
await arborist.reify({ installStrategy: 'linked' })

const storeDir = path.join(dir, 'node_modules', '.store')
const storeEntries = fs.readdirSync(storeDir)

// typescript should still be in the store because mylib needs it as a prod dep
t.ok(storeEntries.some(e => e.startsWith('typescript@')), 'typescript is in store (workspace prod dep)')
t.ok(storeEntries.some(e => e.startsWith('which@')), 'which is in store')

// root should NOT have a symlink to typescript (it's a dev dep of root)
const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has symlink to prod dep which')
t.ok(rootNmEntries.includes('mylib'), 'root has symlink to prod workspace mylib')
t.notOk(rootNmEntries.includes('typescript'), 'root does not have symlink to dev dep typescript')
t.notOk(rootNmEntries.includes('devtool'), 'root does not have symlink to dev workspace devtool')

// workspace should have a symlink to typescript (it's a prod dep of mylib)
const wsNmEntries = fs.readdirSync(path.join(dir, 'packages', 'mylib', 'node_modules'))
t.ok(wsNmEntries.includes('typescript'), 'workspace has symlink to prod dep typescript')
})

tap.test('omit optional dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'fsevents', version: '1.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
optionalDependencies: { fsevents: '1.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { fsevents: '1.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['optional'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('fsevents'), 'root does not have optional dep fsevents')
})

tap.test('omit peer dependencies with linked strategy', async t => {
const graph = {
registry: [
{ name: 'which', version: '1.0.0' },
{ name: 'react', version: '18.0.0' },
],
root: {
name: 'myapp',
version: '1.0.0',
dependencies: { which: '1.0.0' },
peerDependencies: { react: '18.0.0' },
},
workspaces: [
{
name: 'mylib',
version: '1.0.0',
dependencies: { react: '18.0.0' },
},
],
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const arborist = new Arborist({
path: dir,
registry,
packumentCache: new Map(),
cache,
omit: ['peer'],
})
await arborist.reify({ installStrategy: 'linked' })

const rootNmEntries = fs.readdirSync(path.join(dir, 'node_modules'))
t.ok(rootNmEntries.includes('which'), 'root has prod dep which')
t.notOk(rootNmEntries.includes('react'), 'root does not have peer dep react')
})

/*
* TO TEST:
* --------------------------------------
Expand Down
Loading