diff --git a/.changeset/native-pkg-command.md b/.changeset/native-pkg-command.md new file mode 100644 index 0000000000..968d863a20 --- /dev/null +++ b/.changeset/native-pkg-command.md @@ -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. diff --git a/cspell.json b/cspell.json index c3d1db973e..e9f43c2280 100644 --- a/cspell.json +++ b/cspell.json @@ -134,6 +134,7 @@ "idempotency", "imagetools", "imurmurhash", + "invalidformat", "ionicons", "isexe", "istvan", @@ -320,6 +321,7 @@ "stdtype", "streamsearch", "stringifying", + "subcmd", "subdep", "subdependencies", "subdependency", diff --git a/object/property-path/src/delete.ts b/object/property-path/src/delete.ts new file mode 100644 index 0000000000..c2224000e2 --- /dev/null +++ b/object/property-path/src/delete.ts @@ -0,0 +1,51 @@ +import { parsePropertyPath } from './parse.js' +import { rejectUnsafeKeys } from './unsafeKeys.js' + +type ObjectOrArray = Record | 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): 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)[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)[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)) +} diff --git a/object/property-path/src/index.ts b/object/property-path/src/index.ts index b2ca238e12..b86a274a7c 100644 --- a/object/property-path/src/index.ts +++ b/object/property-path/src/index.ts @@ -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' diff --git a/object/property-path/src/set.ts b/object/property-path/src/set.ts new file mode 100644 index 0000000000..8048c486e9 --- /dev/null +++ b/object/property-path/src/set.ts @@ -0,0 +1,67 @@ +import { PnpmError } from '@pnpm/error' + +import { parsePropertyPath } from './parse.js' +import { rejectUnsafeKeys } from './unsafeKeys.js' + +type ObjectOrArray = Record | 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, 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)[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) diff --git a/object/property-path/src/unsafeKeys.ts b/object/property-path/src/unsafeKeys.ts new file mode 100644 index 0000000000..6ea11b35b2 --- /dev/null +++ b/object/property-path/src/unsafeKeys.ts @@ -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): void { + for (const segment of propertyPath) { + if (typeof segment === 'string' && UNSAFE_KEYS.has(segment)) { + throw new UnsafePropertyPathKeyError(segment) + } + } +} diff --git a/object/property-path/test/delete.test.ts b/object/property-path/test/delete.test.ts new file mode 100644 index 0000000000..08d5cde43e --- /dev/null +++ b/object/property-path/test/delete.test.ts @@ -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 = { name: 'foo', version: '1.0.0' } + deleteObjectValueByPropertyPathString(obj, 'version') + expect(obj).toEqual({ name: 'foo' }) +}) + +test('deletes a nested key', () => { + const obj: Record = { 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 = { 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 = { 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 = { name: 'foo' } + deleteObjectValueByPropertyPathString(obj, 'scripts.test') + expect(obj).toEqual({ name: 'foo' }) +}) + +test('no-op when an intermediate value is null', () => { + const obj: Record = { a: null } + deleteObjectValueByPropertyPathString(obj, 'a.b') + expect(obj).toEqual({ a: null }) +}) + +test('no-op when an intermediate value is a scalar', () => { + const obj: Record = { a: 'scalar' } + deleteObjectValueByPropertyPathString(obj, 'a.b') + expect(obj).toEqual({ a: 'scalar' }) +}) + +test('no-op on an empty property path', () => { + const obj: Record = { 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)) + } +}) diff --git a/object/property-path/test/set.test.ts b/object/property-path/test/set.test.ts new file mode 100644 index 0000000000..a453f0486f --- /dev/null +++ b/object/property-path/test/set.test.ts @@ -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 = { 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 = {} + setObjectValueByPropertyPathString(obj, 'scripts.build', 'tsc') + expect(obj).toEqual({ scripts: { build: 'tsc' } }) +}) + +test('creates an array when the next segment is numeric', () => { + const obj: Record = {} + setObjectValueByPropertyPathString(obj, 'contributors[0].name', 'Alice') + expect(obj).toEqual({ contributors: [{ name: 'Alice' }] }) +}) + +test('replaces a scalar intermediate with the right container', () => { + const obj: Record = { 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 = { 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 = { 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 = { contributors: { x: 1 } } + setObjectValueByPropertyPathString(obj, 'contributors[0]', 'Alice') + expect(obj).toEqual({ contributors: ['Alice'] }) +}) + +test('overwrites an existing value', () => { + const obj: Record = { 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)) + } + // 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) +}) diff --git a/pkg-manifest/commands/package.json b/pkg-manifest/commands/package.json new file mode 100644 index 0000000000..550d07238c --- /dev/null +++ b/pkg-manifest/commands/package.json @@ -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" } +} diff --git a/pkg-manifest/commands/src/index.ts b/pkg-manifest/commands/src/index.ts new file mode 100644 index 0000000000..9030447245 --- /dev/null +++ b/pkg-manifest/commands/src/index.ts @@ -0,0 +1,2 @@ +export * as pkg from './pkg.js' + diff --git a/pkg-manifest/commands/src/pkg.ts b/pkg-manifest/commands/src/pkg.ts new file mode 100644 index 0000000000..edc7a58644 --- /dev/null +++ b/pkg-manifest/commands/src/pkg.ts @@ -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 { + const types = allTypes as Record + return { + dir: types['dir'], + json: Boolean, + recursive: Boolean, + } +} + +export const commandNames = ['pkg'] + +interface PkgCommandOptions { + dir: string + json?: boolean + recursive?: boolean + workspaceDir?: string + selectedProjectsGraph?: Record } }> +} + +export async function handler (opts: PkgCommandOptions, params: string[]): Promise { + 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 { + 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 { + 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 + 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 { + const manifest = await readProjectManifestOnly(opts.dir) as Record + + 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, args: string[]): unknown { + if (args.length === 0) return manifest + const result: Record = {} + for (const key of args) { + result[key] = getObjectValueByPropertyPathString(manifest, key) + } + return result +} + +async function pkgSet (opts: PkgCommandOptions, args: string[]): Promise { + 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, key, value) + } + + await writeProjectManifest(manifest) +} + +async function pkgDelete (opts: PkgCommandOptions, args: string[]): Promise { + 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, key) + } + + await writeProjectManifest(manifest) +} + +async function pkgFix (opts: PkgCommandOptions): Promise { + const { manifest, writeProjectManifest } = await readProjectManifest(opts.dir) + const m = manifest as ProjectManifest & Record + + 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 { + 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 [ [ ...]]', + }, + { + description: 'Sets a value in package.json', + name: 'set = [= ...]', + }, + { + description: 'Deletes a key from package.json', + name: 'delete [ ...]', + }, + { + 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 [ [ ...]]', + 'pnpm pkg set = [= ...]', + 'pnpm pkg delete [ ...]', + 'pnpm pkg fix', + 'pnpm pkg set = --json', + 'pnpm -r pkg get name', + 'pnpm --filter pkg get name', + 'pnpm -r pkg set version=1.0.0', + ], + }) +} diff --git a/pkg-manifest/commands/test/pkg.test.ts b/pkg-manifest/commands/test/pkg.test.ts new file mode 100644 index 0000000000..a41c4dedfc --- /dev/null +++ b/pkg-manifest/commands/test/pkg.test.ts @@ -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 = { + 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 = { + 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>) { + 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' }) + }) + }) +}) diff --git a/pkg-manifest/commands/test/tsconfig.json b/pkg-manifest/commands/test/tsconfig.json new file mode 100644 index 0000000000..67ce5e1d0e --- /dev/null +++ b/pkg-manifest/commands/test/tsconfig.json @@ -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": ".." + } + ] +} diff --git a/pkg-manifest/commands/tsconfig.json b/pkg-manifest/commands/tsconfig.json new file mode 100644 index 0000000000..47f3391e32 --- /dev/null +++ b/pkg-manifest/commands/tsconfig.json @@ -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" } + ] +} diff --git a/pkg-manifest/commands/tsconfig.lint.json b/pkg-manifest/commands/tsconfig.lint.json new file mode 100644 index 0000000000..1bbe711971 --- /dev/null +++ b/pkg-manifest/commands/tsconfig.lint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "../../__typings__/**/*.d.ts" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de4b0bbb3d..63915aa653 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/pnpm/package.json b/pnpm/package.json index b2935f3505..8e0af6a5bb 100644 --- a/pnpm/package.json +++ b/pnpm/package.json @@ -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:", diff --git a/pnpm/src/cmd/index.ts b/pnpm/src/cmd/index.ts index 3feeede611..312f628fdf 100644 --- a/pnpm/src/cmd/index.ts +++ b/pnpm/src/cmd/index.ts @@ -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, diff --git a/pnpm/src/cmd/notImplemented.ts b/pnpm/src/cmd/notImplemented.ts index 6ac937aa77..89422c7af2 100644 --- a/pnpm/src/cmd/notImplemented.ts +++ b/pnpm/src/cmd/notImplemented.ts @@ -8,7 +8,6 @@ const NOT_IMPLEMENTED_COMMANDS = [ 'issues', 'prefix', 'profile', - 'pkg', 'repo', 'set-script', 'team', diff --git a/pnpm/tsconfig.json b/pnpm/tsconfig.json index 705fac84a6..784fa8aa83 100644 --- a/pnpm/tsconfig.json +++ b/pnpm/tsconfig.json @@ -125,6 +125,9 @@ { "path": "../patching/commands" }, + { + "path": "../pkg-manifest/commands" + }, { "path": "../pkg-manifest/reader" }, diff --git a/workspace/project-manifest-reader/src/index.ts b/workspace/project-manifest-reader/src/index.ts index d2f458c182..1e10a039ec 100644 --- a/workspace/project-manifest-reader/src/index.ts +++ b/workspace/project-manifest-reader/src/index.ts @@ -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)