feat: support hidden scripts starting with '.' (#11041)

Scripts starting with '.' are hidden:
- Cannot be run directly via 'pnpm run .script' (throws HIDDEN_SCRIPT error)
- Can only be called from other scripts (detected via npm_lifecycle_event)
- Omitted from 'pnpm run' listing

This allows packages to have internal scripts that are implementation
details, preventing accidental direct execution. Similar to how
dotfiles are hidden in file systems.
This commit is contained in:
Zoltan Kochan
2026-03-20 17:59:11 +01:00
committed by GitHub
parent 996284f8cc
commit 0407e36ab2
5 changed files with 96 additions and 1 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/exec.commands": minor
"pnpm": minor
---
Added support for hidden scripts. Scripts starting with `.` are hidden and cannot be run directly via `pnpm run`. They can only be called from other scripts. Hidden scripts are also omitted from the `pnpm run` listing.

View File

@@ -0,0 +1,25 @@
import { PnpmError } from '@pnpm/error'
/**
* Filters out hidden scripts (starting with '.') when called outside a lifecycle.
* Throws if the user explicitly requested a hidden script by exact name,
* or if all matched scripts are hidden.
*/
export function throwOrFilterHiddenScripts (specifiedScripts: string[], scriptName: string): string[] {
if (specifiedScripts.length === 0) return specifiedScripts
const hidden = specifiedScripts.filter((s) => s.startsWith('.'))
if (hidden.length === 0) return specifiedScripts
// Exact name request for a hidden script
if (scriptName.startsWith('.')) {
throw new PnpmError('HIDDEN_SCRIPT', `Script "${scriptName}" is hidden and cannot be run directly`, {
hint: 'Scripts starting with "." are hidden and can only be called from other scripts.',
})
}
// Regex/glob matched both visible and hidden — filter out hidden
const visible = specifiedScripts.filter((s) => !s.startsWith('.'))
if (visible.length > 0) return visible
// Only hidden scripts matched
throw new PnpmError('HIDDEN_SCRIPT', `All matched scripts are hidden and cannot be run directly: ${hidden.join(', ')}`, {
hint: 'Scripts starting with "." are hidden and can only be called from other scripts.',
})
}

View File

@@ -25,6 +25,7 @@ import { renderHelp } from 'render-help'
import { buildCommandNotFoundHint } from './buildCommandNotFoundHint.js'
import { handler as exec } from './exec.js'
import { existsInDir } from './existsInDir.js'
import { throwOrFilterHiddenScripts } from './hiddenScripts.js'
import { runDepsStatusCheck } from './runDepsStatusCheck.js'
import { getSpecifiedScripts as getSpecifiedScriptWithoutStartCommand, type RecursiveRunOpts, runRecursive } from './runRecursive.js'
@@ -225,7 +226,11 @@ export async function handler (
return printProjectCommands(manifest, rootManifest ?? undefined)
}
const specifiedScripts = getSpecifiedScripts(manifest.scripts ?? {}, scriptName)
let specifiedScripts = getSpecifiedScripts(manifest.scripts ?? {}, scriptName)
if (!process.env.npm_lifecycle_event) {
specifiedScripts = throwOrFilterHiddenScripts(specifiedScripts, scriptName)
}
if (specifiedScripts.length < 1) {
if (opts.ifPresent) return
@@ -348,6 +353,7 @@ function printProjectCommands (
const otherScripts = [] as string[][]
for (const [scriptName, script] of Object.entries(manifest.scripts ?? {})) {
if (scriptName.startsWith('.')) continue
if (ALL_LIFECYCLE_SCRIPTS.has(scriptName)) {
lifecycleScripts.push([scriptName, script])
} else {

View File

@@ -17,6 +17,7 @@ import { realpathMissing } from 'realpath-missing'
import { createEmptyRecursiveSummary, getExecutionDuration, getResumedPackageChunks, writeRecursiveSummary } from './exec.js'
import { existsInDir } from './existsInDir.js'
import { throwOrFilterHiddenScripts } from './hiddenScripts.js'
import { tryBuildRegExpFromCommand } from './regexpCommand.js'
import { runScript, type RunScriptOptions } from './run.js'
@@ -110,6 +111,9 @@ export async function runRecursive (
) {
return
}
if (!process.env.npm_lifecycle_event) {
throwOrFilterHiddenScripts([scriptName], scriptName)
}
result[prefix].status = 'running'
const startTime = process.hrtime()
hasCommand++

View File

@@ -233,3 +233,57 @@ test('--reporter-hide-prefix should hide workspace prefix', async () => {
expect(output).toContain('2')
expect(output).not.toContain('script2: 2')
})
test('hidden scripts (starting with .) cannot be run directly', () => {
prepare({
scripts: {
'.build': 'echo hidden',
'build': 'pnpm run .build',
},
})
const result = execPnpmSync(['run', '.build'])
expect(result.status).toBe(1)
const output = result.stdout.toString() + result.stderr.toString()
expect(output).toContain('HIDDEN_SCRIPT')
})
test('hidden scripts can be called from other scripts', () => {
prepare({
scripts: {
'.build': 'echo hidden-ok',
'build': 'pnpm run .build',
},
})
const result = execPnpmSync(['run', 'build'])
expect(result.status).toBe(0)
expect(result.stdout.toString()).toContain('hidden-ok')
})
test('hidden scripts are not shown in pnpm run listing', () => {
prepare({
scripts: {
'.internal': 'echo hidden',
'build': 'echo visible',
},
})
const result = execPnpmSync(['run'])
expect(result.stdout.toString()).toContain('build')
expect(result.stdout.toString()).not.toContain('.internal')
})
test('regex selector skips hidden scripts', () => {
prepare({
scripts: {
'.build-internal': 'echo hidden',
'build': 'echo visible',
},
})
const result = execPnpmSync(['run', '/build/'])
expect(result.status).toBe(0)
expect(result.stdout.toString()).toContain('visible')
expect(result.stdout.toString()).not.toContain('hidden')
})