feat(pkg): implement native pnpm pkg command (#11512)

Implements `pnpm pkg` natively with `get`, `set`, `delete`, and `fix` subcommands.

Workspace usage follows pnpm conventions: use `-r` / `--recursive` for all selected workspace projects, and `--filter` to narrow the selected project graph. This does not add npm-style `--workspace` or `--workspaces` flags.

The PR also extends `@pnpm/object.property-path` with safe set/delete helpers used by the command.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Alessio Attilio
2026-05-23 22:59:09 +02:00
committed by GitHub
parent 389dae8382
commit d7da112eea
21 changed files with 1055 additions and 2 deletions

View File

@@ -0,0 +1,8 @@
---
"@pnpm/object.property-path": minor
"@pnpm/pkg-manifest.commands": minor
"@pnpm/workspace.project-manifest-reader": patch
"pnpm": minor
---
Implement `pnpm pkg` command natively, following `npm pkg` standards.

View File

@@ -134,6 +134,7 @@
"idempotency",
"imagetools",
"imurmurhash",
"invalidformat",
"ionicons",
"isexe",
"istvan",
@@ -320,6 +321,7 @@
"stdtype",
"streamsearch",
"stringifying",
"subcmd",
"subdep",
"subdependencies",
"subdependency",

View File

@@ -0,0 +1,51 @@
import { parsePropertyPath } from './parse.js'
import { rejectUnsafeKeys } from './unsafeKeys.js'
type ObjectOrArray = Record<string | number, unknown> | unknown[]
/**
* Remove the value at a nested property path on {@link object}.
*
* No-op if the path does not resolve to an existing value. Array elements are
* removed via `splice` so no `null` hole is left behind.
*
* Throws on unsafe keys (`__proto__`, `constructor`, `prototype`) to prevent
* prototype pollution.
*/
export function deleteObjectValueByPropertyPath (object: ObjectOrArray, propertyPath: Iterable<string | number>): void {
const path = Array.from(propertyPath)
if (path.length === 0) return
rejectUnsafeKeys(path)
let obj: ObjectOrArray = object
for (let i = 0; i < path.length - 1; i++) {
const key = path[i]
if (
typeof obj !== 'object' ||
obj === null ||
!Object.hasOwn(obj, key) ||
(Array.isArray(obj) && typeof key !== 'number')
) {
return
}
obj = (obj as Record<string | number, unknown>)[key] as ObjectOrArray
}
if (typeof obj !== 'object' || obj === null) return
const lastKey = path[path.length - 1]
if (Array.isArray(obj) && isArrayIndex(lastKey)) {
obj.splice(Number(lastKey), 1)
return
}
delete (obj as Record<string | number, unknown>)[lastKey]
}
export const deleteObjectValueByPropertyPathString = (object: ObjectOrArray, propertyPath: string): void =>
deleteObjectValueByPropertyPath(object, parsePropertyPath(propertyPath))
function isArrayIndex (key: string | number): boolean {
if (typeof key === 'number') return Number.isInteger(key) && key >= 0
if (!/^(?:0|[1-9]\d*)$/.test(key)) return false
return Number.isSafeInteger(Number(key))
}

View File

@@ -1,3 +1,6 @@
export * from './delete.js'
export * from './get.js'
export * from './parse.js'
export * from './set.js'
export * from './token/index.js'
export * from './unsafeKeys.js'

View File

@@ -0,0 +1,67 @@
import { PnpmError } from '@pnpm/error'
import { parsePropertyPath } from './parse.js'
import { rejectUnsafeKeys } from './unsafeKeys.js'
type ObjectOrArray = Record<string | number, unknown> | unknown[]
export class EmptyPropertyPathError extends PnpmError {
constructor () {
super('EMPTY_PROPERTY_PATH', 'Cannot set a value with an empty property path')
}
}
/**
* Set the value at a nested property path on {@link object}.
*
* Creates intermediate objects or arrays as needed. If an intermediate node
* exists but its shape disagrees with the next path segment (a scalar where a
* container is needed, an array where an object is needed, or vice versa), it
* is replaced with a fresh container so the write is persisted in a shape that
* round-trips through `JSON.stringify`.
*
* Throws on unsafe keys (`__proto__`, `constructor`, `prototype`) to prevent
* prototype pollution.
*/
export function setObjectValueByPropertyPath (object: ObjectOrArray, propertyPath: Iterable<string | number>, value: unknown): void {
const path = Array.from(propertyPath)
if (path.length === 0) throw new EmptyPropertyPathError()
rejectUnsafeKeys(path)
let obj: ObjectOrArray = object
for (let i = 0; i < path.length - 1; i++) {
const key = path[i]
const current = (obj as Record<string | number, unknown>)[key]
const needsArray = typeof path[i + 1] === 'number'
const isContainer = typeof current === 'object' && current !== null
if (!isContainer || Array.isArray(current) !== needsArray) {
const replacement: ObjectOrArray = needsArray ? [] : {}
defineOwnProperty(obj, key, replacement)
obj = replacement
} else {
obj = current as ObjectOrArray
}
}
defineOwnProperty(obj, path[path.length - 1], value)
}
/**
* Set a value as an own enumerable, writable, configurable property.
*
* Using `Object.defineProperty` rather than bracket assignment ensures that
* even if a `__proto__`-like key slipped past {@link rejectUnsafeKeys}, the
* write would create an own property instead of invoking the prototype
* setter, so this assignment site cannot be a prototype-pollution sink.
*/
function defineOwnProperty (obj: ObjectOrArray, key: string | number, value: unknown): void {
Object.defineProperty(obj, key, {
value,
writable: true,
enumerable: true,
configurable: true,
})
}
export const setObjectValueByPropertyPathString = (object: ObjectOrArray, propertyPath: string, value: unknown): void =>
setObjectValueByPropertyPath(object, parsePropertyPath(propertyPath), value)

View File

@@ -0,0 +1,23 @@
import { PnpmError } from '@pnpm/error'
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
export class UnsafePropertyPathKeyError extends PnpmError {
readonly key: string
constructor (key: string) {
super('UNSAFE_PROPERTY_PATH_KEY', `Key "${key}" is not allowed in a property path`)
this.key = key
}
}
/**
* Throw if the property path contains a key that could trigger prototype
* pollution when used to mutate an object (e.g. via {@link setObjectValueByPropertyPath}).
*/
export function rejectUnsafeKeys (propertyPath: Iterable<string | number>): void {
for (const segment of propertyPath) {
if (typeof segment === 'string' && UNSAFE_KEYS.has(segment)) {
throw new UnsafePropertyPathKeyError(segment)
}
}
}

View File

@@ -0,0 +1,64 @@
import { expect, test } from '@jest/globals'
import {
deleteObjectValueByPropertyPathString,
type UnsafePropertyPathKeyError,
} from '../src/index.js'
test('deletes a top-level key', () => {
const obj: Record<string, unknown> = { name: 'foo', version: '1.0.0' }
deleteObjectValueByPropertyPathString(obj, 'version')
expect(obj).toEqual({ name: 'foo' })
})
test('deletes a nested key', () => {
const obj: Record<string, unknown> = { scripts: { build: 'tsc', test: 'jest' } }
deleteObjectValueByPropertyPathString(obj, 'scripts.test')
expect(obj).toEqual({ scripts: { build: 'tsc' } })
})
test('removes an array element without leaving a hole', () => {
const obj: Record<string, unknown> = { contributors: [{ name: 'Alice' }, { name: 'Bob' }] }
deleteObjectValueByPropertyPathString(obj, 'contributors[0]')
expect(obj).toEqual({ contributors: [{ name: 'Bob' }] })
})
test('removes an array element by string index without leaving a hole', () => {
const obj: Record<string, unknown> = { contributors: [{ name: 'Alice' }, { name: 'Bob' }] }
deleteObjectValueByPropertyPathString(obj, 'contributors["0"]')
expect(obj).toEqual({ contributors: [{ name: 'Bob' }] })
})
test('no-op on a missing path', () => {
const obj: Record<string, unknown> = { name: 'foo' }
deleteObjectValueByPropertyPathString(obj, 'scripts.test')
expect(obj).toEqual({ name: 'foo' })
})
test('no-op when an intermediate value is null', () => {
const obj: Record<string, unknown> = { a: null }
deleteObjectValueByPropertyPathString(obj, 'a.b')
expect(obj).toEqual({ a: null })
})
test('no-op when an intermediate value is a scalar', () => {
const obj: Record<string, unknown> = { a: 'scalar' }
deleteObjectValueByPropertyPathString(obj, 'a.b')
expect(obj).toEqual({ a: 'scalar' })
})
test('no-op on an empty property path', () => {
const obj: Record<string, unknown> = { name: 'foo' }
deleteObjectValueByPropertyPathString(obj, '')
expect(obj).toEqual({ name: 'foo' })
})
test('rejects __proto__, constructor and prototype keys', () => {
for (const unsafe of ['__proto__', 'constructor', 'prototype']) {
expect(() => deleteObjectValueByPropertyPathString({}, `${unsafe}.foo`))
.toThrow(expect.objectContaining({
code: 'ERR_PNPM_UNSAFE_PROPERTY_PATH_KEY',
key: unsafe,
} as Partial<UnsafePropertyPathKeyError>))
}
})

View File

@@ -0,0 +1,71 @@
import { expect, test } from '@jest/globals'
import {
EmptyPropertyPathError,
setObjectValueByPropertyPathString,
UnsafePropertyPathKeyError,
} from '../src/index.js'
test('sets a top-level key', () => {
const obj: Record<string, unknown> = { name: 'foo' }
setObjectValueByPropertyPathString(obj, 'version', '1.0.0')
expect(obj).toEqual({ name: 'foo', version: '1.0.0' })
})
test('creates missing intermediate objects', () => {
const obj: Record<string, unknown> = {}
setObjectValueByPropertyPathString(obj, 'scripts.build', 'tsc')
expect(obj).toEqual({ scripts: { build: 'tsc' } })
})
test('creates an array when the next segment is numeric', () => {
const obj: Record<string, unknown> = {}
setObjectValueByPropertyPathString(obj, 'contributors[0].name', 'Alice')
expect(obj).toEqual({ contributors: [{ name: 'Alice' }] })
})
test('replaces a scalar intermediate with the right container', () => {
const obj: Record<string, unknown> = { scripts: 'echo hi' }
setObjectValueByPropertyPathString(obj, 'scripts.test', 'vitest')
expect(obj).toEqual({ scripts: { test: 'vitest' } })
})
test('replaces a scalar intermediate with an array when the next segment is numeric', () => {
const obj: Record<string, unknown> = { keywords: 'oops' }
setObjectValueByPropertyPathString(obj, 'keywords[0]', 'pnpm')
expect(obj).toEqual({ keywords: ['pnpm'] })
})
test('replaces an array intermediate with an object when the next segment is a string', () => {
const obj: Record<string, unknown> = { contributors: [] }
setObjectValueByPropertyPathString(obj, 'contributors.name', 'Alice')
expect(obj).toEqual({ contributors: { name: 'Alice' } })
})
test('replaces an object intermediate with an array when the next segment is numeric', () => {
const obj: Record<string, unknown> = { contributors: { x: 1 } }
setObjectValueByPropertyPathString(obj, 'contributors[0]', 'Alice')
expect(obj).toEqual({ contributors: ['Alice'] })
})
test('overwrites an existing value', () => {
const obj: Record<string, unknown> = { scripts: { build: 'old' } }
setObjectValueByPropertyPathString(obj, 'scripts.build', 'new')
expect(obj).toEqual({ scripts: { build: 'new' } })
})
test('rejects __proto__, constructor and prototype keys', () => {
for (const unsafe of ['__proto__', 'constructor', 'prototype']) {
expect(() => setObjectValueByPropertyPathString({}, `${unsafe}.polluted`, true))
.toThrow(expect.objectContaining({
code: 'ERR_PNPM_UNSAFE_PROPERTY_PATH_KEY',
key: unsafe,
} as Partial<UnsafePropertyPathKeyError>))
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(({} as any).polluted).toBeUndefined()
})
test('throws on empty property path', () => {
expect(() => setObjectValueByPropertyPathString({}, '', 'value')).toThrow(EmptyPropertyPathError)
})

View File

@@ -0,0 +1,38 @@
{
"name": "@pnpm/pkg-manifest.commands",
"version": "1100.0.0",
"description": "Commands for managing package.json",
"keywords": ["pnpm", "pnpm11", "pkg"],
"license": "MIT",
"funding": "https://opencollective.com/pnpm",
"repository": "https://github.com/pnpm/pnpm/tree/main/pkg-manifest/commands",
"homepage": "https://github.com/pnpm/pnpm/tree/main/pkg-manifest/commands#readme",
"bugs": { "url": "https://github.com/pnpm/pnpm/issues" },
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": { ".": "./lib/index.js" },
"files": ["lib", "!*.map"],
"scripts": {
"compile": "tsgo --build && pn lint --fix",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
"test": "pn compile && pn .test",
".test": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules --disable-warning=ExperimentalWarning --disable-warning=DEP0169\" jest",
"prepublishOnly": "tsgo --build"
},
"dependencies": {
"@pnpm/cli.utils": "workspace:*",
"@pnpm/config.reader": "workspace:*",
"@pnpm/error": "workspace:*",
"@pnpm/object.property-path": "workspace:*",
"@pnpm/types": "workspace:*",
"render-help": "catalog:"
},
"devDependencies": {
"@jest/globals": "catalog:",
"@pnpm/pkg-manifest.commands": "workspace:*",
"@pnpm/prepare": "workspace:*"
},
"engines": { "node": ">=22.13" },
"jest": { "preset": "@pnpm/jest-config" }
}

View File

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

View File

@@ -0,0 +1,253 @@
import path from 'node:path'
import { docsUrl, readProjectManifest, readProjectManifestOnly } from '@pnpm/cli.utils'
import { types as allTypes } from '@pnpm/config.reader'
import { PnpmError } from '@pnpm/error'
import {
deleteObjectValueByPropertyPathString,
getObjectValueByPropertyPathString,
setObjectValueByPropertyPathString,
} from '@pnpm/object.property-path'
import type { ProjectManifest } from '@pnpm/types'
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'],
json: Boolean,
recursive: Boolean,
}
}
export const commandNames = ['pkg']
interface PkgCommandOptions {
dir: string
json?: boolean
recursive?: boolean
workspaceDir?: string
selectedProjectsGraph?: Record<string, { package: { rootDir: string, manifest: Record<string, unknown> } }>
}
export async function handler (opts: PkgCommandOptions, params: string[]): Promise<string | void> {
if (params.length === 0) {
throw new PnpmError('PKG_MISSING_SUBCOMMAND', 'Missing subcommand', {
hint: help(),
})
}
if (params[0] === '--help' || params[0] === '-h') {
return help()
}
const [subcmd, ...args] = params
if (opts.recursive) {
return handleRecursiveCommand(opts, subcmd, args)
}
return runSubcommand(opts, subcmd, args)
}
async function runSubcommand (opts: PkgCommandOptions, subcmd: string, args: string[]): Promise<string | void> {
switch (subcmd) {
case 'get':
return pkgGet(opts, args)
case 'set':
return pkgSet(opts, args)
case 'delete':
return pkgDelete(opts, args)
case 'fix':
return pkgFix(opts)
default:
throw new PnpmError('PKG_UNKNOWN_SUBCOMMAND', `Unknown subcommand "${subcmd}"`, {
hint: help(),
})
}
}
async function handleRecursiveCommand (opts: PkgCommandOptions, subcmd: string, args: string[]): Promise<string | void> {
const workspaceDir = opts.workspaceDir
if (!workspaceDir) {
throw new PnpmError('PKG_RECURSIVE_NO_ROOT', 'Cannot run recursively outside of a workspace')
}
const selectedProjects = opts.selectedProjectsGraph == null
? []
: Object.values(opts.selectedProjectsGraph)
if (selectedProjects.length === 0) {
throw new PnpmError('PKG_RECURSIVE_NO_PACKAGES', 'No workspace packages were selected')
}
if (subcmd === 'get') {
const entries = await Promise.all(selectedProjects.map(async ({ package: pkg }) => {
const manifest = await readProjectManifestOnly(pkg.rootDir) as Record<string, unknown>
const pkgName = String(manifest.name ?? path.relative(workspaceDir, pkg.rootDir))
return [pkgName, selectFromManifest(manifest, args)] as const
}))
return JSON.stringify(Object.fromEntries(entries), undefined, 2)
}
await Promise.all(selectedProjects.map(({ package: pkg }) =>
runSubcommand({ ...opts, dir: pkg.rootDir }, subcmd, args)
))
}
async function pkgGet (opts: PkgCommandOptions, args: string[]): Promise<string> {
const manifest = await readProjectManifestOnly(opts.dir) as Record<string, unknown>
if (args.length === 1) {
const value = getObjectValueByPropertyPathString(manifest, args[0])
if (value === undefined) return ''
if (opts.json) return JSON.stringify(value, undefined, 2)
return typeof value === 'string' ? value : JSON.stringify(value, undefined, 2)
}
return JSON.stringify(selectFromManifest(manifest, args), undefined, 2)
}
function selectFromManifest (manifest: Record<string, unknown>, args: string[]): unknown {
if (args.length === 0) return manifest
const result: Record<string, unknown> = {}
for (const key of args) {
result[key] = getObjectValueByPropertyPathString(manifest, key)
}
return result
}
async function pkgSet (opts: PkgCommandOptions, args: string[]): Promise<void> {
if (args.length === 0) {
throw new PnpmError('PKG_SET_MISSING_ARGS', 'Missing key=value pairs', {
hint: help(),
})
}
const { manifest, writeProjectManifest } = await readProjectManifest(opts.dir)
for (const arg of args) {
const eqIndex = arg.indexOf('=')
if (eqIndex === -1) {
throw new PnpmError('PKG_SET_INVALID_ARG', `Invalid argument "${arg}". Expected key=value format`, {
hint: 'Example: pnpm pkg set name=my-package',
})
}
const key = arg.slice(0, eqIndex)
let value: unknown = arg.slice(eqIndex + 1)
if (opts.json) {
try {
value = JSON.parse(value as string)
} catch {
throw new PnpmError('PKG_SET_JSON_PARSE', `Failed to parse value as JSON: "${value as string}"`)
}
}
setObjectValueByPropertyPathString(manifest as unknown as Record<string, unknown>, key, value)
}
await writeProjectManifest(manifest)
}
async function pkgDelete (opts: PkgCommandOptions, args: string[]): Promise<void> {
if (args.length === 0) {
throw new PnpmError('PKG_DELETE_MISSING_ARGS', 'Missing keys to delete', {
hint: help(),
})
}
const { manifest, writeProjectManifest } = await readProjectManifest(opts.dir)
for (const key of args) {
deleteObjectValueByPropertyPathString(manifest as unknown as Record<string, unknown>, key)
}
await writeProjectManifest(manifest)
}
async function pkgFix (opts: PkgCommandOptions): Promise<void> {
const { manifest, writeProjectManifest } = await readProjectManifest(opts.dir)
const m = manifest as ProjectManifest & Record<string, unknown>
if ('name' in m && typeof m.name !== 'string') {
delete m.name
}
if ('version' in m && typeof m.version !== 'string') {
delete m.version
}
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies', 'scripts'] as const) {
if (field in m && !isPlainObject(m[field])) {
delete m[field]
}
}
if ('bin' in m && typeof m.bin !== 'string' && !isPlainObject(m.bin)) {
delete m.bin
}
await writeProjectManifest(manifest)
}
function isPlainObject (value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function help (): string {
return renderHelp({
description: 'Manages your package.json',
descriptionLists: [
{
title: 'Commands',
list: [
{
description: 'Retrieves a value from package.json',
name: 'get [<key> [<key> ...]]',
},
{
description: 'Sets a value in package.json',
name: 'set <key>=<value> [<key>=<value> ...]',
},
{
description: 'Deletes a key from package.json',
name: 'delete <key> [<key> ...]',
},
{
description: 'Auto corrects common errors in package.json',
name: 'fix',
},
],
},
{
title: 'Options',
list: [
{
description: 'When setting, parse the value as JSON. When getting a single key, return its JSON-encoded form instead of the raw value',
name: '--json',
},
{
description: 'Run on every workspace project or every project selected by a filter',
name: '--recursive',
shortAlias: '-r',
},
],
},
],
url: docsUrl('pkg'),
usages: [
'pnpm pkg get [<key> [<key> ...]]',
'pnpm pkg set <key>=<value> [<key>=<value> ...]',
'pnpm pkg delete <key> [<key> ...]',
'pnpm pkg fix',
'pnpm pkg set <key>=<value> --json',
'pnpm -r pkg get name',
'pnpm --filter <selector> pkg get name',
'pnpm -r pkg set version=1.0.0',
],
})
}

View File

@@ -0,0 +1,387 @@
import fs from 'node:fs'
import path from 'node:path'
import { beforeEach, describe, expect, test } from '@jest/globals'
import { pkg } from '@pnpm/pkg-manifest.commands'
import { tempDir } from '@pnpm/prepare'
const { cliOptionsTypes, handler } = pkg
describe('pkg command', () => {
let tmpDir: string
beforeEach(() => {
tmpDir = tempDir()
})
describe('get subcommand', () => {
test('gets all fields when no keys provided', async () => {
const manifest = {
name: 'test-package',
version: '1.0.0',
dependencies: { foo: '1.0.0' },
}
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
const result = await handler({ dir: tmpDir }, ['get'])
const parsed = JSON.parse(result as string)
expect(parsed.name).toBe('test-package')
expect(parsed.version).toBe('1.0.0')
})
test('gets a single key as a raw value', async () => {
const manifest = { name: 'test-package', version: '1.0.0' }
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
expect(await handler({ dir: tmpDir }, ['get', 'name'])).toBe('test-package')
})
test('returns an empty string when a single key is missing', async () => {
const manifest = { name: 'test-package' }
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
expect(await handler({ dir: tmpDir }, ['get', 'description'])).toBe('')
})
test('gets a single key as JSON when --json is set', async () => {
const manifest = { name: 'test-package' }
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
expect(await handler({ dir: tmpDir, json: true }, ['get', 'name'])).toBe('"test-package"')
})
test('returns an object when multiple keys are requested', async () => {
const manifest = {
name: 'test-package',
version: '1.0.0',
description: 'A test package',
}
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
const parsed = JSON.parse(await handler({ dir: tmpDir }, ['get', 'name', 'version']) as string)
expect(parsed.name).toBe('test-package')
expect(parsed.version).toBe('1.0.0')
expect(parsed.description).toBeUndefined()
})
test('gets nested keys using dot notation', async () => {
const manifest = {
name: 'test-package',
scripts: { build: 'tsc', test: 'jest' },
}
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
expect(await handler({ dir: tmpDir }, ['get', 'scripts.build'])).toBe('tsc')
})
})
describe('set subcommand', () => {
test('sets a simple key-value pair', async () => {
const manifest = { name: 'test-package' }
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
await handler({ dir: tmpDir }, ['set', 'version=1.0.0'])
const updated = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(updated.version).toBe('1.0.0')
})
test('sets nested keys using dot notation', async () => {
const manifest = { name: 'test-package' }
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
await handler({ dir: tmpDir }, ['set', 'scripts.build=tsc'])
const updated = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(updated.scripts.build).toBe('tsc')
})
test('sets multiple key-value pairs', async () => {
const manifest = { name: 'test-package' }
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
await handler({ dir: tmpDir }, ['set', 'version=1.0.0', 'description=A test'])
const updated = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(updated.version).toBe('1.0.0')
expect(updated.description).toBe('A test')
})
test('sets JSON values with --json flag', async () => {
const manifest = { name: 'test-package' }
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
await handler({ dir: tmpDir, json: true }, ['set', 'version=2'])
const updated = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(updated.version).toBe(2)
})
test('throws error for invalid key=value format', async () => {
const manifest = { name: 'test-package' }
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
await expect(handler({ dir: tmpDir }, ['set', 'invalidformat'])).rejects.toThrow()
})
test('sets nested values through array index notation', async () => {
const manifest = { name: 'test-package' }
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
await handler({ dir: tmpDir }, ['set', 'contributors[0].name=Alice'])
const updated = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(updated.contributors).toEqual([{ name: 'Alice' }])
})
test('replaces a scalar intermediate value with an object when descending', async () => {
const manifest = { scripts: 'echo hi' }
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
await handler({ dir: tmpDir }, ['set', 'scripts.test=vitest'])
const updated = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(updated.scripts).toEqual({ test: 'vitest' })
})
test('rejects unsafe keys to prevent prototype pollution', async () => {
const manifest = { name: 'test-package' }
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
await expect(handler({ dir: tmpDir }, ['set', '__proto__.polluted=true'])).rejects.toThrow()
await expect(handler({ dir: tmpDir }, ['set', 'constructor.prototype.polluted=true'])).rejects.toThrow()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(({} as any).polluted).toBeUndefined()
})
})
describe('delete subcommand', () => {
test('deletes a key from package.json', async () => {
const manifest = {
name: 'test-package',
version: '1.0.0',
description: 'To be deleted',
}
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
await handler({ dir: tmpDir }, ['delete', 'description'])
const updated = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(updated.description).toBeUndefined()
expect(updated.name).toBe('test-package')
})
test('deletes nested keys using dot notation', async () => {
const manifest = {
name: 'test-package',
scripts: { build: 'tsc', test: 'jest' },
}
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
await handler({ dir: tmpDir }, ['delete', 'scripts.test'])
const updated = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(updated.scripts.test).toBeUndefined()
expect(updated.scripts.build).toBe('tsc')
})
test('throws error when no keys provided', async () => {
const manifest = { name: 'test-package' }
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
await expect(handler({ dir: tmpDir }, ['delete'])).rejects.toThrow()
})
test('removes an array element without leaving a hole', async () => {
const manifest = {
name: 'test-package',
contributors: [{ name: 'Alice' }, { name: 'Bob' }],
}
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
await handler({ dir: tmpDir }, ['delete', 'contributors[0]'])
const updated = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(updated.contributors).toEqual([{ name: 'Bob' }])
})
})
describe('fix subcommand', () => {
test('fixes invalid name field', async () => {
const manifest = {
name: 123 as unknown,
version: '1.0.0',
}
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
await handler({ dir: tmpDir }, ['fix'])
const updated = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(updated.name).toBeUndefined()
expect(updated.version).toBe('1.0.0')
})
test('fixes invalid dependencies field', async () => {
const manifest = {
name: 'test-package',
dependencies: 'invalid' as unknown,
}
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
await handler({ dir: tmpDir }, ['fix'])
const updated = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(updated.dependencies).toBeUndefined()
})
test('removes array-valued object fields', async () => {
const manifest: Record<string, unknown> = {
name: 'test-package',
dependencies: [],
scripts: [],
}
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
await handler({ dir: tmpDir }, ['fix'])
const updated = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(updated.dependencies).toBeUndefined()
expect(updated.scripts).toBeUndefined()
})
test('removes null-valued object fields', async () => {
const manifest: Record<string, unknown> = {
name: 'test-package',
dependencies: null,
}
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
await handler({ dir: tmpDir }, ['fix'])
const updated = JSON.parse(fs.readFileSync(path.join(tmpDir, 'package.json'), 'utf8'))
expect(updated.dependencies).toBeUndefined()
})
})
describe('error handling', () => {
test('throws error for unknown subcommand', async () => {
const manifest = { name: 'test-package' }
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(manifest, null, 2))
await expect(handler({ dir: tmpDir }, ['unknown'])).rejects.toThrow()
})
test('throws error when no subcommand provided', async () => {
await expect(handler({ dir: tmpDir }, [])).rejects.toThrow()
})
})
describe('cliOptionsTypes', () => {
test('returns correct option types', () => {
const types = cliOptionsTypes()
expect(types).toHaveProperty('dir')
expect(types).toHaveProperty('json')
expect(types).toHaveProperty('recursive')
expect(types).not.toHaveProperty('workspace')
expect(types).not.toHaveProperty('workspaces')
expect(types).not.toHaveProperty('ws')
})
})
describe('recursive mode', () => {
function setupWorkspace (manifests: Record<string, Record<string, unknown>>) {
const allProjects = Object.entries(manifests).map(([name, manifest]) => {
const rootDir = path.join(tmpDir, name)
fs.mkdirSync(rootDir, { recursive: true })
fs.writeFileSync(path.join(rootDir, 'package.json'), JSON.stringify(manifest, null, 2))
return { rootDir, manifest }
})
const selectedProjectsGraph = Object.fromEntries(
allProjects.map(p => [p.rootDir, { package: p }])
)
return { allProjects, selectedProjectsGraph }
}
test('aggregates `get` results from each selected workspace package', async () => {
const { selectedProjectsGraph } = setupWorkspace({
'pkg-a': { name: 'pkg-a', version: '1.0.0' },
'pkg-b': { name: 'pkg-b', version: '2.0.0' },
})
const result = await handler({
dir: tmpDir,
workspaceDir: tmpDir,
recursive: true,
selectedProjectsGraph,
}, ['get', 'name'])
expect(JSON.parse(result as string)).toEqual({
'pkg-a': { name: 'pkg-a' },
'pkg-b': { name: 'pkg-b' },
})
})
test('runs `set` against every selected workspace package', async () => {
const { allProjects, selectedProjectsGraph } = setupWorkspace({
'pkg-a': { name: 'pkg-a', version: '1.0.0' },
'pkg-b': { name: 'pkg-b', version: '2.0.0' },
})
await handler({
dir: tmpDir,
workspaceDir: tmpDir,
recursive: true,
selectedProjectsGraph,
}, ['set', 'license=MIT'])
for (const { rootDir } of allProjects) {
const updated = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8'))
expect(updated.license).toBe('MIT')
}
})
test('runs `delete` against every selected workspace package', async () => {
const { allProjects, selectedProjectsGraph } = setupWorkspace({
'pkg-a': { name: 'pkg-a', version: '1.0.0', extra: 'a' },
'pkg-b': { name: 'pkg-b', version: '2.0.0', extra: 'b' },
})
await handler({
dir: tmpDir,
workspaceDir: tmpDir,
recursive: true,
selectedProjectsGraph,
}, ['delete', 'extra'])
for (const { rootDir } of allProjects) {
const updated = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8'))
expect(updated.extra).toBeUndefined()
}
})
test('runs against only the selected projects', async () => {
const { allProjects, selectedProjectsGraph } = setupWorkspace({
'pkg-a': { name: 'pkg-a', version: '1.0.0' },
'pkg-b': { name: 'pkg-b', version: '2.0.0' },
'pkg-c': { name: 'pkg-c', version: '3.0.0' },
})
const selected = Object.fromEntries(
[allProjects[0], allProjects[2]].map(p => [p.rootDir, selectedProjectsGraph[p.rootDir]])
)
await handler({
dir: tmpDir,
workspaceDir: tmpDir,
recursive: true,
selectedProjectsGraph: selected,
}, ['set', 'license=MIT'])
const read = (name: string) =>
JSON.parse(fs.readFileSync(path.join(tmpDir, name, 'package.json'), 'utf8'))
expect(read('pkg-a').license).toBe('MIT')
expect(read('pkg-b').license).toBeUndefined()
expect(read('pkg-c').license).toBe('MIT')
})
test('throws when used outside of a workspace', async () => {
await expect(handler({ dir: tmpDir, recursive: true }, ['get']))
.rejects.toMatchObject({ code: 'ERR_PNPM_PKG_RECURSIVE_NO_ROOT' })
})
test('throws when no workspace packages were selected', async () => {
await expect(handler({
dir: tmpDir,
workspaceDir: tmpDir,
recursive: true,
selectedProjectsGraph: {},
}, ['get'])).rejects.toMatchObject({ code: 'ERR_PNPM_PKG_RECURSIVE_NO_PACKAGES' })
})
})
})

View File

@@ -0,0 +1,18 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "../node_modules/.test.lib",
"rootDir": "..",
"isolatedModules": true
},
"include": [
"**/*.ts",
"../../../__typings__/**/*.d.ts"
],
"references": [
{
"path": ".."
}
]
}

View File

@@ -0,0 +1,19 @@
{
"extends": "@pnpm/tsconfig",
"compilerOptions": {
"outDir": "lib",
"rootDir": "src"
},
"include": [
"src/**/*.ts",
"../../__typings__/**/*.d.ts"
],
"references": [
{ "path": "../../__utils__/prepare" },
{ "path": "../../cli/utils" },
{ "path": "../../config/reader" },
{ "path": "../../core/error" },
{ "path": "../../core/types" },
{ "path": "../../object/property-path" }
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": [
"src/**/*.ts",
"test/**/*.ts",
"../../__typings__/**/*.d.ts"
]
}

34
pnpm-lock.yaml generated
View File

@@ -7601,6 +7601,37 @@ importers:
specifier: workspace:*
version: 'link:'
pkg-manifest/commands:
dependencies:
'@pnpm/cli.utils':
specifier: workspace:*
version: link:../../cli/utils
'@pnpm/config.reader':
specifier: workspace:*
version: link:../../config/reader
'@pnpm/error':
specifier: workspace:*
version: link:../../core/error
'@pnpm/object.property-path':
specifier: workspace:*
version: link:../../object/property-path
'@pnpm/types':
specifier: workspace:*
version: link:../../core/types
render-help:
specifier: 'catalog:'
version: 2.0.0
devDependencies:
'@jest/globals':
specifier: 'catalog:'
version: 30.3.0
'@pnpm/pkg-manifest.commands':
specifier: workspace:*
version: 'link:'
'@pnpm/prepare':
specifier: workspace:*
version: link:../../__utils__/prepare
pkg-manifest/reader:
dependencies:
'@pnpm/error':
@@ -7777,6 +7808,9 @@ importers:
'@pnpm/patching.commands':
specifier: workspace:*
version: link:../patching/commands
'@pnpm/pkg-manifest.commands':
specifier: workspace:*
version: link:../pkg-manifest/commands
'@pnpm/pkg-manifest.reader':
specifier: workspace:*
version: link:../pkg-manifest/reader

View File

@@ -112,6 +112,7 @@
"@pnpm/logger": "workspace:*",
"@pnpm/nopt": "catalog:",
"@pnpm/patching.commands": "workspace:*",
"@pnpm/pkg-manifest.commands": "workspace:*",
"@pnpm/pkg-manifest.reader": "workspace:*",
"@pnpm/prepare": "workspace:*",
"@pnpm/registry-mock": "catalog:",

View File

@@ -18,6 +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 { 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'
@@ -150,6 +151,7 @@ const commands: CommandDefinition[] = [
selfUpdate,
init,
install,
pkg,
installTest,
link,
list,

View File

@@ -8,7 +8,6 @@ const NOT_IMPLEMENTED_COMMANDS = [
'issues',
'prefix',
'profile',
'pkg',
'repo',
'set-script',
'team',

View File

@@ -125,6 +125,9 @@
{
"path": "../patching/commands"
},
{
"path": "../pkg-manifest/commands"
},
{
"path": "../pkg-manifest/reader"
},

View File

@@ -290,7 +290,7 @@ function normalize (manifest: ProjectManifest): ProjectManifest {
for (const key in manifest) {
if (Object.hasOwn(manifest, key)) {
const value = manifest[key as keyof ProjectManifest]
if (typeof value !== 'object' || !dependencyKeys.has(key)) {
if (typeof value !== 'object' || value === null || !dependencyKeys.has(key)) {
result[key] = structuredClone(value)
} else {
const keys = Object.keys(value)