From d55263fff58e8219fb03a2256ead62e08ae47458 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Sat, 23 May 2026 23:50:29 +0200 Subject: [PATCH] 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 --- .changeset/native-set-script-command.md | 6 ++ pkg-manifest/commands/src/index.ts | 2 +- pkg-manifest/commands/src/setScript.ts | 40 ++++++++++ pkg-manifest/commands/test/setScript.test.ts | 78 ++++++++++++++++++++ pnpm/src/cmd/index.ts | 3 +- pnpm/src/cmd/notImplemented.ts | 1 - 6 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 .changeset/native-set-script-command.md create mode 100644 pkg-manifest/commands/src/setScript.ts create mode 100644 pkg-manifest/commands/test/setScript.test.ts diff --git a/.changeset/native-set-script-command.md b/.changeset/native-set-script-command.md new file mode 100644 index 0000000000..a89833bee4 --- /dev/null +++ b/.changeset/native-set-script-command.md @@ -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. diff --git a/pkg-manifest/commands/src/index.ts b/pkg-manifest/commands/src/index.ts index 9030447245..ee3ce483f2 100644 --- a/pkg-manifest/commands/src/index.ts +++ b/pkg-manifest/commands/src/index.ts @@ -1,2 +1,2 @@ export * as pkg from './pkg.js' - +export * as setScript from './setScript.js' diff --git a/pkg-manifest/commands/src/setScript.ts b/pkg-manifest/commands/src/setScript.ts new file mode 100644 index 0000000000..c6642319b9 --- /dev/null +++ b/pkg-manifest/commands/src/setScript.ts @@ -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 { + const types = allTypes as Record + return { dir: types['dir'] } +} + +export const commandNames = ['set-script', 'ss'] + +export async function handler ( + opts: { dir: string }, + params: string[] +): Promise { + 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, ['scripts', name], command) + await writeProjectManifest(manifest) +} + +export function help (): string { + return renderHelp({ + description: 'Set a script in package.json', + usages: ['pnpm set-script '], + url: docsUrl('set-script'), + }) +} diff --git a/pkg-manifest/commands/test/setScript.test.ts b/pkg-manifest/commands/test/setScript.test.ts new file mode 100644 index 0000000000..953b94e5ea --- /dev/null +++ b/pkg-manifest/commands/test/setScript.test.ts @@ -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() + }) +}) diff --git a/pnpm/src/cmd/index.ts b/pnpm/src/cmd/index.ts index 312f628fdf..8cc61b10a8 100644 --- a/pnpm/src/cmd/index.ts +++ b/pnpm/src/cmd/index.ts @@ -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, diff --git a/pnpm/src/cmd/notImplemented.ts b/pnpm/src/cmd/notImplemented.ts index 89422c7af2..ab1885ed52 100644 --- a/pnpm/src/cmd/notImplemented.ts +++ b/pnpm/src/cmd/notImplemented.ts @@ -9,7 +9,6 @@ const NOT_IMPLEMENTED_COMMANDS = [ 'prefix', 'profile', 'repo', - 'set-script', 'team', 'token', 'xmas',