diff --git a/.changeset/private-scripts.md b/.changeset/private-scripts.md new file mode 100644 index 0000000000..bebed79df2 --- /dev/null +++ b/.changeset/private-scripts.md @@ -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. diff --git a/exec/commands/src/hiddenScripts.ts b/exec/commands/src/hiddenScripts.ts new file mode 100644 index 0000000000..4aa83335b8 --- /dev/null +++ b/exec/commands/src/hiddenScripts.ts @@ -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.', + }) +} diff --git a/exec/commands/src/run.ts b/exec/commands/src/run.ts index 6108ed5027..4524dbfa78 100644 --- a/exec/commands/src/run.ts +++ b/exec/commands/src/run.ts @@ -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 { diff --git a/exec/commands/src/runRecursive.ts b/exec/commands/src/runRecursive.ts index 3fb4b137be..4510a879e9 100644 --- a/exec/commands/src/runRecursive.ts +++ b/exec/commands/src/runRecursive.ts @@ -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++ diff --git a/pnpm/test/run.ts b/pnpm/test/run.ts index d21136dad7..a310b0418c 100644 --- a/pnpm/test/run.ts +++ b/pnpm/test/run.ts @@ -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') +})