mirror of
https://github.com/pnpm/pnpm.git
synced 2026-04-27 18:46:18 -04:00
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:
6
.changeset/private-scripts.md
Normal file
6
.changeset/private-scripts.md
Normal 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.
|
||||
25
exec/commands/src/hiddenScripts.ts
Normal file
25
exec/commands/src/hiddenScripts.ts
Normal 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.',
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user