feat(pkg-manifest): add native set-script command with ss alias (#11504)

* feat: add native set-script command with ss alias

* refactor(pkg-manifest): host set-script and wire it into the CLI

- Move set-script into @pnpm/pkg-manifest.commands (drops the orphan
  @pnpm/pkg.commands package; pkg/* is not in the workspace).
- Use readProjectManifest from @pnpm/cli.utils so package.json5 and
  package.yaml are updated in place instead of growing a stray
  package.json.
- Remove set-script from notImplemented and register the command in
  pnpm/src/cmd/index.ts.
- Cover the ss alias and the multi-word command path in tests.

* refactor(set-script): share the pkg-set primitive

Replace direct manifest.scripts mutation with
setObjectValueByPropertyPath - the same primitive pkg-set uses. Reuses
the prototype-pollution rejection for free and keeps the two commands
on the same write path. Avoids the pkg-set string-CLI's first-equals
key/value split, so script names containing '=' work too.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Alessio Attilio
2026-05-23 23:50:29 +02:00
committed by GitHub
parent d7da112eea
commit d55263fff5
6 changed files with 127 additions and 3 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/pkg-manifest.commands": minor
"pnpm": minor
---
Implements `pnpm set-script` (alias `ss`) natively. Adds or updates an entry in the `scripts` field of the project manifest, supporting `package.json`, `package.json5`, and `package.yaml` formats.

View File

@@ -1,2 +1,2 @@
export * as pkg from './pkg.js'
export * as setScript from './setScript.js'

View File

@@ -0,0 +1,40 @@
import { docsUrl, readProjectManifest } from '@pnpm/cli.utils'
import { types as allTypes } from '@pnpm/config.reader'
import { PnpmError } from '@pnpm/error'
import { setObjectValueByPropertyPath } from '@pnpm/object.property-path'
import { renderHelp } from 'render-help'
export const rcOptionsTypes = cliOptionsTypes
export function cliOptionsTypes (): Record<string, unknown> {
const types = allTypes as Record<string, unknown>
return { dir: types['dir'] }
}
export const commandNames = ['set-script', 'ss']
export async function handler (
opts: { dir: string },
params: string[]
): Promise<void> {
if (params.length < 2) {
throw new PnpmError('SET_SCRIPT_MISSING_ARGS', 'Missing script name or command', {
hint: help(),
})
}
const [name, ...commandParts] = params
const command = commandParts.join(' ')
const { manifest, writeProjectManifest } = await readProjectManifest(opts.dir)
setObjectValueByPropertyPath(manifest as unknown as Record<string, unknown>, ['scripts', name], command)
await writeProjectManifest(manifest)
}
export function help (): string {
return renderHelp({
description: 'Set a script in package.json',
usages: ['pnpm set-script <name> <command>'],
url: docsUrl('set-script'),
})
}

View File

@@ -0,0 +1,78 @@
import fs from 'node:fs'
import path from 'node:path'
import { beforeEach, describe, expect, test } from '@jest/globals'
import { setScript } from '@pnpm/pkg-manifest.commands'
import { tempDir } from '@pnpm/prepare'
describe('set-script command', () => {
let tmpDir: string
beforeEach(() => {
tmpDir = tempDir()
fs.writeFileSync(
path.join(tmpDir, 'package.json'),
JSON.stringify({ name: 'test-package', version: '1.0.0' }, null, 2)
)
})
test('exposes the ss alias', () => {
expect(setScript.commandNames).toEqual(['set-script', 'ss'])
})
test('adds a script when none exist', async () => {
await setScript.handler({ dir: tmpDir }, ['build', 'tsc -b'])
const written = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(written.scripts).toEqual({ build: 'tsc -b' })
})
test('overwrites an existing script', async () => {
fs.writeFileSync(
path.join(tmpDir, 'package.json'),
JSON.stringify({ name: 'test-package', scripts: { build: 'old' } }, null, 2)
)
await setScript.handler({ dir: tmpDir }, ['build', 'tsc -b'])
const written = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(written.scripts.build).toBe('tsc -b')
})
test('joins remaining params into the command', async () => {
await setScript.handler({ dir: tmpDir }, ['lint', 'eslint', '--fix', 'src'])
const written = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(written.scripts.lint).toBe('eslint --fix src')
})
test('accepts script names that contain dots, hyphens, and quotes', async () => {
await setScript.handler({ dir: tmpDir }, ['my-build', 'tsc -b'])
await setScript.handler({ dir: tmpDir }, ['pre.publish', 'echo'])
await setScript.handler({ dir: tmpDir }, ['weird"name', 'echo weird'])
const written = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(written.scripts).toEqual({
'my-build': 'tsc -b',
'pre.publish': 'echo',
'weird"name': 'echo weird',
})
})
test('accepts script names containing an equals sign', async () => {
await setScript.handler({ dir: tmpDir }, ['with=eq', 'echo with=eq'])
const written = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(written.scripts['with=eq']).toBe('echo with=eq')
})
test('throws when arguments are missing', async () => {
await expect(setScript.handler({ dir: tmpDir }, ['build']))
.rejects.toThrow('Missing script name or command')
})
test('rejects unsafe script names', async () => {
await expect(setScript.handler({ dir: tmpDir }, ['__proto__', 'echo']))
.rejects.toThrow()
})
})

View File

@@ -18,7 +18,7 @@ import {
} from '@pnpm/exec.commands'
import { add, dedupe, fetch, importCommand, install, link, prune, remove, unlink, update } from '@pnpm/installing.commands'
import { patch, patchCommit, patchRemove } from '@pnpm/patching.commands'
import { pkg } from '@pnpm/pkg-manifest.commands'
import { pkg, setScript } from '@pnpm/pkg-manifest.commands'
import { deprecate, distTag, owner, ping, search, star, stars, undeprecate, unpublish, unstar, whoami } from '@pnpm/registry-access.commands'
import { deploy, pack, packApp, publish, stage, version } from '@pnpm/releasing.commands'
import { catFile, catIndex, findHash, store } from '@pnpm/store.commands'
@@ -178,6 +178,7 @@ const commands: CommandDefinition[] = [
root,
run,
sbom,
setScript,
setup,
search,
star,

View File

@@ -9,7 +9,6 @@ const NOT_IMPLEMENTED_COMMANDS = [
'prefix',
'profile',
'repo',
'set-script',
'team',
'token',
'xmas',