diff options
author | Ruy Adorno <ruyadorno@hotmail.com> | 2021-06-28 18:37:08 +0300 |
---|---|---|
committer | Ruy Adorno <ruyadorno@hotmail.com> | 2021-07-12 18:04:38 +0300 |
commit | f17aca5cdf355aaa7e1f517d1b3bb4213f4df092 (patch) | |
tree | c3bbe8872bc9ec52a2fa24c3efb5cc2fb3d48ad5 | |
parent | 74c99755e522f9cfc0d602841568d5e1f835fcaf (diff) |
feat: npm pkg
Implements `npm pkg get|set|delete` support. It enables retrieving and
modifying values in a `package.json` file of any given project.
Included are the implementation based on https://github.com/npm/rfcs/pull/402
along with extensive tests and user documentation.
Relates to: https://github.com/npm/rfcs/pull/402
Fixes: https://github.com/npm/statusboard/issues/368
PR-URL: https://github.com/npm/cli/pull/3487
Credit: @ruyadorno
Close: #3487
Reviewed-by: @wraithgar
32 files changed, 2293 insertions, 70 deletions
diff --git a/docs/content/commands/npm-audit.md b/docs/content/commands/npm-audit.md index 704d7a15f..94b16b27b 100644 --- a/docs/content/commands/npm-audit.md +++ b/docs/content/commands/npm-audit.md @@ -232,6 +232,7 @@ mistakes, unnecessary performance degradation, and malicious input. * Allow unpublishing all versions of a published package. * Allow conflicting peerDependencies to be installed in the root project. * Implicitly set `--yes` during `npm init`. +* Allow clobbering existing values in `npm pkg` If you don't have a clear idea of what you want to do, it is strongly recommended that you do not use this option! @@ -243,6 +244,9 @@ recommended that you do not use this option! Whether or not to output JSON data, rather than the normal output. +* In `npm pkg set` it enables parsing set values with JSON.parse() before + saving them to your `package.json`. + Not supported by all npm commands. #### `package-lock-only` diff --git a/docs/content/commands/npm-config.md b/docs/content/commands/npm-config.md index f2868cb89..992e9ef70 100644 --- a/docs/content/commands/npm-config.md +++ b/docs/content/commands/npm-config.md @@ -104,6 +104,9 @@ global config. Whether or not to output JSON data, rather than the normal output. +* In `npm pkg set` it enables parsing set values with JSON.parse() before + saving them to your `package.json`. + Not supported by all npm commands. #### `global` diff --git a/docs/content/commands/npm-explain.md b/docs/content/commands/npm-explain.md index 0e50d7ae4..3a87ee8e4 100644 --- a/docs/content/commands/npm-explain.md +++ b/docs/content/commands/npm-explain.md @@ -63,6 +63,9 @@ node_modules/nyc/node_modules/find-up Whether or not to output JSON data, rather than the normal output. +* In `npm pkg set` it enables parsing set values with JSON.parse() before + saving them to your `package.json`. + Not supported by all npm commands. #### `workspace` diff --git a/docs/content/commands/npm-fund.md b/docs/content/commands/npm-fund.md index 3dc5292b4..ec5f5a37f 100644 --- a/docs/content/commands/npm-fund.md +++ b/docs/content/commands/npm-fund.md @@ -73,6 +73,9 @@ test-workspaces-fund@1.0.0 Whether or not to output JSON data, rather than the normal output. +* In `npm pkg set` it enables parsing set values with JSON.parse() before + saving them to your `package.json`. + Not supported by all npm commands. #### `browser` diff --git a/docs/content/commands/npm-init.md b/docs/content/commands/npm-init.md index 23e8e70d9..54c3bdb4b 100644 --- a/docs/content/commands/npm-init.md +++ b/docs/content/commands/npm-init.md @@ -175,6 +175,7 @@ mistakes, unnecessary performance degradation, and malicious input. * Allow unpublishing all versions of a published package. * Allow conflicting peerDependencies to be installed in the root project. * Implicitly set `--yes` during `npm init`. +* Allow clobbering existing values in `npm pkg` If you don't have a clear idea of what you want to do, it is strongly recommended that you do not use this option! diff --git a/docs/content/commands/npm-ls.md b/docs/content/commands/npm-ls.md index 1f401fa95..350f40a99 100644 --- a/docs/content/commands/npm-ls.md +++ b/docs/content/commands/npm-ls.md @@ -91,6 +91,9 @@ upon by the current project. Whether or not to output JSON data, rather than the normal output. +* In `npm pkg set` it enables parsing set values with JSON.parse() before + saving them to your `package.json`. + Not supported by all npm commands. #### `long` diff --git a/docs/content/commands/npm-org.md b/docs/content/commands/npm-org.md index e6df560ac..269f5cc3e 100644 --- a/docs/content/commands/npm-org.md +++ b/docs/content/commands/npm-org.md @@ -87,6 +87,9 @@ password, npm will prompt on the command line for one. Whether or not to output JSON data, rather than the normal output. +* In `npm pkg set` it enables parsing set values with JSON.parse() before + saving them to your `package.json`. + Not supported by all npm commands. #### `parseable` diff --git a/docs/content/commands/npm-outdated.md b/docs/content/commands/npm-outdated.md index bc9263d7a..40e5feafd 100644 --- a/docs/content/commands/npm-outdated.md +++ b/docs/content/commands/npm-outdated.md @@ -104,6 +104,9 @@ upon by the current project. Whether or not to output JSON data, rather than the normal output. +* In `npm pkg set` it enables parsing set values with JSON.parse() before + saving them to your `package.json`. + Not supported by all npm commands. #### `long` diff --git a/docs/content/commands/npm-pack.md b/docs/content/commands/npm-pack.md index 950702627..cd4a17591 100644 --- a/docs/content/commands/npm-pack.md +++ b/docs/content/commands/npm-pack.md @@ -34,6 +34,9 @@ Note: This is NOT honored by other network related commands, eg `dist-tags`, Whether or not to output JSON data, rather than the normal output. +* In `npm pkg set` it enables parsing set values with JSON.parse() before + saving them to your `package.json`. + Not supported by all npm commands. #### `pack-destination` diff --git a/docs/content/commands/npm-pkg.md b/docs/content/commands/npm-pkg.md new file mode 100644 index 000000000..7ff0a4d97 --- /dev/null +++ b/docs/content/commands/npm-pkg.md @@ -0,0 +1,238 @@ +--- +title: npm-pkg +section: 1 +description: Manages your package.json +--- + +### Synopsis + +```bash +npm pkg get [<field> [.<subfield> ...]] +npm pkg set <field>=<value> [.<subfield>=<value> ...] +npm pkg delete <field> [.<subfield> ...] +``` + +### Description + +A command that automates the management of `package.json` files. +`npm pkg` provide 3 different sub commands that allow you to modify or retrieve +values for given object keys in your `packge.json`. + +The syntax to retrieve and set fields is a dot separated representation of +the nested object properties to be found within your `package.json`, it's the +same notation used in [`npm view`](/commands/npm-view) to retrieve information +from the registry manifest, below you can find more examples on how to use it. + +Returned values are always in **json** format. + +* `npm pkg get <field>` + + Retrieves a value `key`, defined in your `package.json` file. + + For example, in order to retrieve the name of the current package, you + can run: + + ```bash + npm pkg get name + ``` + + It's also possible to retrieve multiple values at once: + + ```bash + npm pkg get name version + ``` + + You can view child fields by separating them with a period. To retrieve + the value of a test `script` value, you would run the following command: + + ```bash + npm pkg get scripts.test + ``` + + For fields that are arrays, requesting a non-numeric field will return + all of the values from the objects in the list. For example, to get all + the contributor emails for a package, you would run: + + ```bash + npm pkg get contributors.email + ``` + + You may also use numeric indices in square braces to specifically select + an item in an array field. To just get the email address of the first + contributor in the list, you can run: + + ```bash + npm pkg get contributors[0].email + ``` + +* `npm pkg set <field>=<value>` + + Sets a `value` in your `package.json` based on the `field` value. When + saving to your `package.json` file the same set of rules used during + `npm install` and other cli commands that touches the `package.json` file + are used, making sure to respect the existing indentation and possibly + applying some validation prior to saving values to the file. + + The same syntax used to retrieve values from your package can also be used + to define new properties or overriding existing ones, below are some + examples of how the dot separated syntax can be used to edit your + `package.json` file. + + Defining a new bin named `mynewcommand` in your `package.json` that points + to a file `cli.js`: + + ```bash + npm pkg set bin.mynewcommand=cli.js + ``` + + Setting multiple fields at once is also possible: + + ```bash + npm pkg set description='Awesome package' engines.node='>=10' + ``` + + It's also possible to add to array values, for example to add a new + contributor entry: + + ```bash + npm pkg set contributors[0].name='Foo' contributors[0].email='foo@bar.ca' + ``` + + It's also possible to parse values as json prior to saving them to your + `package.json` file, for example in order to set a `"private": true` + property: + + ```bash + npm pkg set private=true --json + ``` + + It also enables saving values as numbers: + + ```bash + npm pkg set tap.timeout=60 --json + ``` + +* `npm pkg delete <key>` + + Deletes a `key` from your `package.json` + + The same syntax used to set values from your package can also be used + to remove existing ones. For example, in order to remove a script named + build: + + ```bash + npm pkg delete scripts.build + ``` + +### Workspaces support + +You can set/get/delete items across your configured workspaces by using the +`workspace` or `workspaces` config options. + +For example, setting a `funding` value across all configured workspaces +of a project: + +```bash +npm pkg set funding=https://example.com --ws +``` + +When using `npm pkg get` to retrieve info from your configured workspaces, the +returned result will be in a json format in which top level keys are the +names of each workspace, the values of these keys will be the result values +returned from each of the configured workspaces, e.g: + +``` +npm pkg get name version --ws +{ + "a": { + "name": "a", + "version": "1.0.0" + }, + "b": { + "name": "b", + "version": "1.0.0" + } +} +``` + +### Configuration + +<!-- AUTOGENERATED CONFIG DESCRIPTIONS START --> +<!-- automatically generated, do not edit manually --> +#### `force` + +* Default: false +* Type: Boolean + +Removes various protections against unfortunate side effects, common +mistakes, unnecessary performance degradation, and malicious input. + +* Allow clobbering non-npm files in global installs. +* Allow the `npm version` command to work on an unclean git repository. +* Allow deleting the cache folder with `npm cache clean`. +* Allow installing packages that have an `engines` declaration requiring a + different version of npm. +* Allow installing packages that have an `engines` declaration requiring a + different version of `node`, even if `--engine-strict` is enabled. +* Allow `npm audit fix` to install modules outside your stated dependency + range (including SemVer-major changes). +* Allow unpublishing all versions of a published package. +* Allow conflicting peerDependencies to be installed in the root project. +* Implicitly set `--yes` during `npm init`. +* Allow clobbering existing values in `npm pkg` + +If you don't have a clear idea of what you want to do, it is strongly +recommended that you do not use this option! + +#### `json` + +* Default: false +* Type: Boolean + +Whether or not to output JSON data, rather than the normal output. + +* In `npm pkg set` it enables parsing set values with JSON.parse() before + saving them to your `package.json`. + +Not supported by all npm commands. + +#### `workspace` + +* Default: +* Type: String (can be set multiple times) + +Enable running a command in the context of the configured workspaces of the +current project while filtering by running only the workspaces defined by +this configuration option. + +Valid values for the `workspace` config are either: + +* Workspace names +* Path to a workspace directory +* Path to a parent workspace directory (will result to selecting all of the + nested workspaces) + +When set for the `npm init` command, this may be set to the folder of a +workspace which does not yet exist, to create the folder and set it up as a +brand new workspace within the project. + +This value is not exported to the environment for child processes. + +#### `workspaces` + +* Default: false +* Type: Boolean + +Enable running a command in the context of **all** the configured +workspaces. + +This value is not exported to the environment for child processes. + +<!-- AUTOGENERATED CONFIG DESCRIPTIONS END --> +## See Also + +* [npm install](/commands/npm-install) +* [npm init](/commands/npm-init) +* [npm config](/commands/npm-config) +* [npm set-script](/commands/npm-set-script) +* [workspaces](/using-npm/workspaces) diff --git a/docs/content/commands/npm-profile.md b/docs/content/commands/npm-profile.md index 63aa46540..079440d78 100644 --- a/docs/content/commands/npm-profile.md +++ b/docs/content/commands/npm-profile.md @@ -91,6 +91,9 @@ The base URL of the npm registry. Whether or not to output JSON data, rather than the normal output. +* In `npm pkg set` it enables parsing set values with JSON.parse() before + saving them to your `package.json`. + Not supported by all npm commands. #### `parseable` diff --git a/docs/content/commands/npm-prune.md b/docs/content/commands/npm-prune.md index ecb6bdcd6..d9b5b068f 100644 --- a/docs/content/commands/npm-prune.md +++ b/docs/content/commands/npm-prune.md @@ -75,6 +75,9 @@ Note: This is NOT honored by other network related commands, eg `dist-tags`, Whether or not to output JSON data, rather than the normal output. +* In `npm pkg set` it enables parsing set values with JSON.parse() before + saving them to your `package.json`. + Not supported by all npm commands. #### `workspace` diff --git a/docs/content/commands/npm-search.md b/docs/content/commands/npm-search.md index 08c955e64..e30287635 100644 --- a/docs/content/commands/npm-search.md +++ b/docs/content/commands/npm-search.md @@ -55,6 +55,9 @@ Show extended information in `ls`, `search`, and `help-search`. Whether or not to output JSON data, rather than the normal output. +* In `npm pkg set` it enables parsing set values with JSON.parse() before + saving them to your `package.json`. + Not supported by all npm commands. #### `color` diff --git a/docs/content/commands/npm-team.md b/docs/content/commands/npm-team.md index 31b09c7ce..c7d5defcc 100644 --- a/docs/content/commands/npm-team.md +++ b/docs/content/commands/npm-team.md @@ -138,6 +138,9 @@ Output parseable results from commands that write to standard output. For Whether or not to output JSON data, rather than the normal output. +* In `npm pkg set` it enables parsing set values with JSON.parse() before + saving them to your `package.json`. + Not supported by all npm commands. <!-- AUTOGENERATED CONFIG DESCRIPTIONS END --> diff --git a/docs/content/commands/npm-unpublish.md b/docs/content/commands/npm-unpublish.md index bc8fbc7a5..82779ab65 100644 --- a/docs/content/commands/npm-unpublish.md +++ b/docs/content/commands/npm-unpublish.md @@ -82,6 +82,7 @@ mistakes, unnecessary performance degradation, and malicious input. * Allow unpublishing all versions of a published package. * Allow conflicting peerDependencies to be installed in the root project. * Implicitly set `--yes` during `npm init`. +* Allow clobbering existing values in `npm pkg` If you don't have a clear idea of what you want to do, it is strongly recommended that you do not use this option! diff --git a/docs/content/commands/npm-version.md b/docs/content/commands/npm-version.md index d24207d1e..a3e34153a 100644 --- a/docs/content/commands/npm-version.md +++ b/docs/content/commands/npm-version.md @@ -47,6 +47,9 @@ Tag the commit when using the `npm version` command. Whether or not to output JSON data, rather than the normal output. +* In `npm pkg set` it enables parsing set values with JSON.parse() before + saving them to your `package.json`. + Not supported by all npm commands. #### `preid` diff --git a/docs/content/commands/npm-view.md b/docs/content/commands/npm-view.md index 8cbd3267b..b3d5df86e 100644 --- a/docs/content/commands/npm-view.md +++ b/docs/content/commands/npm-view.md @@ -49,7 +49,7 @@ npm view opts@$(npm view ronn dependencies.opts) For fields that are arrays, requesting a non-numeric field will return all of the values from the objects in the list. For example, to get all -the contributor names for the `express` package, you would run: +the contributor email addresses for the `express` package, you would run: ```bash npm view express contributors.email @@ -105,6 +105,9 @@ npm view connect versions Whether or not to output JSON data, rather than the normal output. +* In `npm pkg set` it enables parsing set values with JSON.parse() before + saving them to your `package.json`. + Not supported by all npm commands. #### `workspace` diff --git a/docs/content/using-npm/config.md b/docs/content/using-npm/config.md index 103689510..1feae3d64 100644 --- a/docs/content/using-npm/config.md +++ b/docs/content/using-npm/config.md @@ -495,6 +495,7 @@ mistakes, unnecessary performance degradation, and malicious input. * Allow unpublishing all versions of a published package. * Allow conflicting peerDependencies to be installed in the root project. * Implicitly set `--yes` during `npm init`. +* Allow clobbering existing values in `npm pkg` If you don't have a clear idea of what you want to do, it is strongly recommended that you do not use this option! @@ -694,6 +695,9 @@ number, if not already set in package.json. Whether or not to output JSON data, rather than the normal output. +* In `npm pkg set` it enables parsing set values with JSON.parse() before + saving them to your `package.json`. + Not supported by all npm commands. #### `key` diff --git a/lib/pkg.js b/lib/pkg.js new file mode 100644 index 000000000..9ba92c930 --- /dev/null +++ b/lib/pkg.js @@ -0,0 +1,152 @@ +const PackageJson = require('@npmcli/package-json') +const BaseCommand = require('./base-command.js') +const Queryable = require('./utils/queryable.js') + +class Pkg extends BaseCommand { + static get description () { + return 'Manages your package.json' + } + + /* istanbul ignore next - see test/lib/load-all-commands.js */ + static get name () { + return 'pkg' + } + + /* istanbul ignore next - see test/lib/load-all-commands.js */ + static get usage () { + return [ + 'set <key>=<value> [<key>=<value> ...]', + 'get [<key> [<key> ...]]', + 'delete <key> [<key> ...]', + ] + } + + /* istanbul ignore next - see test/lib/load-all-commands.js */ + static get params () { + return [ + 'force', + 'json', + 'workspace', + 'workspaces', + ] + } + + exec (args, cb) { + this.prefix = this.npm.localPrefix + this.pkg(args).then(() => cb()).catch(cb) + } + + execWorkspaces (args, filters, cb) { + this.pkgWorkspaces(args, filters).then(() => cb()).catch(cb) + } + + async pkg (args) { + if (this.npm.config.get('global')) { + throw Object.assign( + new Error(`There's no package.json file to manage on global mode`), + { code: 'EPKGGLOBAL' } + ) + } + + const [cmd, ..._args] = args + switch (cmd) { + case 'get': + return this.get(_args) + case 'set': + return this.set(_args) + case 'delete': + return this.delete(_args) + default: + throw this.usageError() + } + } + + async pkgWorkspaces (args, filters) { + await this.setWorkspaces(filters) + const result = {} + for (const [workspaceName, workspacePath] of this.workspaces.entries()) { + this.prefix = workspacePath + result[workspaceName] = await this.pkg(args) + } + // when running in workspaces names, make sure to key by workspace + // name the results of each value retrieved in each ws + this.npm.output(JSON.stringify(result, null, 2)) + } + + async get (args) { + const pkgJson = await PackageJson.load(this.prefix) + + const { content } = pkgJson + let result = !args.length && content + + if (!result) { + const q = new Queryable(content) + result = q.query(args) + + // in case there's only a single result from the query + // just prints that one element to stdout + if (Object.keys(result).length === 1) + result = result[args] + } + + // only outputs if not running with workspaces config, + // in case you're retrieving info for workspaces the pkgWorkspaces + // will handle the output to make sure it get keyed by ws name + if (!this.workspaces) + this.npm.output(JSON.stringify(result, null, 2)) + + return result + } + + async set (args) { + const setError = () => + Object.assign( + new TypeError('npm pkg set expects a key=value pair of args.'), + { code: 'EPKGSET' } + ) + + if (!args.length) + throw setError() + + const force = this.npm.config.get('force') + const json = this.npm.config.get('json') + const pkgJson = await PackageJson.load(this.prefix) + const q = new Queryable(pkgJson.content) + for (const arg of args) { + const [key, ...rest] = arg.split('=') + const value = rest.join('=') + if (!key || !value) + throw setError() + + q.set(key, json ? JSON.parse(value) : value, { force }) + } + + pkgJson.update(q.toJSON()) + await pkgJson.save() + } + + async delete (args) { + const setError = () => + Object.assign( + new TypeError('npm pkg delete expects key args.'), + { code: 'EPKGDELETE' } + ) + + if (!args.length) + throw setError() + + const pkgJson = await PackageJson.load(this.prefix) + const q = new Queryable(pkgJson.content) + for (const key of args) { + if (!key) + throw setError() + + q.delete(key) + } + + pkgJson.update(q.toJSON()) + await pkgJson.save() + } +} + +module.exports = Pkg diff --git a/lib/utils/cmd-list.js b/lib/utils/cmd-list.js index c865cdabb..26da53900 100644 --- a/lib/utils/cmd-list.js +++ b/lib/utils/cmd-list.js @@ -122,6 +122,7 @@ const cmdList = [ 'diff', 'dist-tag', 'ping', + 'pkg', 'test', 'stop', diff --git a/lib/utils/config/definitions.js b/lib/utils/config/definitions.js index d540b0fc6..5375aeced 100644 --- a/lib/utils/config/definitions.js +++ b/lib/utils/config/definitions.js @@ -715,6 +715,7 @@ define('force', { * Allow unpublishing all versions of a published package. * Allow conflicting peerDependencies to be installed in the root project. * Implicitly set \`--yes\` during \`npm init\`. + * Allow clobbering existing values in \`npm pkg\` If you don't have a clear idea of what you want to do, it is strongly recommended that you do not use this option! @@ -1029,6 +1030,9 @@ define('json', { description: ` Whether or not to output JSON data, rather than the normal output. + * In \`npm pkg set\` it enables parsing set values with JSON.parse() + before saving them to your \`package.json\`. + Not supported by all npm commands. `, flatten, diff --git a/lib/utils/queryable.js b/lib/utils/queryable.js new file mode 100644 index 000000000..173877e64 --- /dev/null +++ b/lib/utils/queryable.js @@ -0,0 +1,253 @@ +const util = require('util') +const _data = Symbol('data') +const _delete = Symbol('delete') + +const sqBracketsMatcher = str => str.match(/(.+)\[([^\]]+)\](.*)$/) + +const cleanLeadingDot = str => + str && str.startsWith('.') ? str.substr(1) : str + +const parseKeys = (key) => { + const sqBracketItems = new Set() + const parseSqBrackets = (str) => { + const index = sqBracketsMatcher(str) + + // once we find square brackets, we recursively parse all these + if (index) { + const preSqBracketPortion = index[1] + + // we want to have a `new String` wrapper here in order to differentiate + // between multiple occurences of the same string, e.g: + // foo.bar[foo.bar] should split into { foo: { bar: { 'foo.bar': {} } } + /* eslint-disable-next-line no-new-wrappers */ + const foundKey = new String(index[2]) + const postSqBracketPortion = cleanLeadingDot(index[3]) + + // we keep track of items found during this step to make sure + // we don't try to split-separate keys that were defined within + // square brackets, since the key name itself might contain dots + sqBracketItems.add(foundKey) + + // returns an array that contains either dot-separate items (that will + // be splitted appart during the next step OR the fully parsed keys + // read from square brackets, e.g: + // foo.bar[1.0.0].a.b -> ['foo.bar', '1.0.0', 'a.b'] + return [ + ...parseSqBrackets(preSqBracketPortion), + foundKey, + ...( + postSqBracketPortion + ? parseSqBrackets(postSqBracketPortion) + : [] + ), + ] + } + + return [str] + } + + const res = [] + // starts by parsing items defined as square brackets, those might be + // representing properties that have a dot in the name or just array + // indexes, e.g: foo[1.0.0] or list[0] + const sqBracketKeys = parseSqBrackets(key.trim()) + + for (const k of sqBracketKeys) { + // keys parsed from square brackets should just be added to list of + // resulting keys as they might have dots as part of the key + if (sqBracketItems.has(k)) + res.push(k) + else { + // splits the dot-sep property names and add them to the list of keys + for (const splitKey of k.split('.')) + /* eslint-disable-next-line no-new-wrappers */ + res.push(new String(splitKey)) + } + } + + // returns an ordered list of strings in which each entry + // represents a key in an object defined by the previous entry + return res +} + +const getter = ({ data, key }) => { + // keys are a list in which each entry represents the name of + // a property that should be walked through the object in order to + // return the final found value + const keys = parseKeys(key) + let _data = data + let label = '' + + for (const k of keys) { + // extra logic to take into account printing array, along with its + // special syntax in which using a dot-sep property name after an + // arry will expand it's results, e.g: + // arr.name -> arr[0].name=value, arr[1].name=value, ... + const maybeIndex = Number(k) + if (Array.isArray(_data) && !Number.isInteger(maybeIndex)) { + _data = _data.reduce((acc, i, index) => { + acc[`${label}[${index}].${k}`] = i[k] + return acc + }, {}) + return _data + } else { + // if can't find any more values, it means it's just over + // and there's nothing to return + if (!_data[k]) + return undefined + + // otherwise sets the next value + _data = _data[k] + } + + label += k + } + + // these are some legacy expectations from + // the old API consumed by lib/view.js + if (Array.isArray(_data) && _data.length <= 1) + _data = _data[0] + + return { + [key]: _data, + } +} + +const setter = ({ data, key, value, force }) => { + // setter goes to recursively transform the provided data obj, + // setting properties from the list of parsed keys, e.g: + // ['foo', 'bar', 'baz'] -> { foo: { bar: { baz: {} } } + const keys = parseKeys(key) + const setKeys = (_data, _key) => { + // handles array indexes, making sure the new array is created if + // missing and properly casting the index to a number + const maybeIndex = Number(_key) + if (!Number.isNaN(maybeIndex)) { + _key = maybeIndex + if (!Object.keys(_data).length) + _data = [] + } + + // retrieves the next data object to recursively iterate on, + // throws if trying to override a literal value or add props to an array + const next = () => { + const haveContents = + !force && + _data[_key] != null && + value !== _delete + const shouldNotOverrideLiteralValue = + !(typeof _data[_key] === 'object') + // if the next obj to recurse is an array and the next key to be + // appended to the resulting obj is not an array index, then it + // should throw since we can't append arbitrary props to arrays + const shouldNotAddPropsToArrays = + Array.isArray(_data[_key]) && + Number.isNaN(Number(keys[0])) + + const overrideError = + haveContents && + (shouldNotOverrideLiteralValue || shouldNotAddPropsToArrays) + + if (overrideError) { + throw Object.assign( + new Error(`Property ${key} already has a value in place.`), + { code: 'EOVERRIDEVALUE' } + ) + } + + return typeof _data[_key] === 'object' ? _data[_key] || {} : {} + } + + // sets items from the parsed array of keys as objects, recurses to + // setKeys in case there are still items to be handled, otherwise it + // just sets the original value set by the user + if (keys.length) + _data[_key] = setKeys(next(), keys.shift()) + else { + // handles special deletion cases for obj props / array items + if (value === _delete) { + if (Array.isArray(_data)) + _data.splice(_key, 1) + else + delete _data[_key] + } else + // finally, sets the value in its right place + _data[_key] = value + } + + return _data + } + + setKeys(data, keys.shift()) +} + +class Queryable { + constructor (obj) { + if (!obj || typeof obj !== 'object') { + throw Object.assign( + new Error('Queryable needs an object to query properties from.'), + { code: 'ENOQUERYABLEOBJ' } + ) + } + + this[_data] = obj + } + + query (queries) { + // this ugly interface here is meant to be a compatibility layer + // with the legacy API lib/view.js is consuming, if at some point + // we refactor that command then we can revisit making this nicer + if (queries === '') + return { '': this[_data] } + + const q = query => getter({ + data: this[_data], + key: query, + }) + + if (Array.isArray(queries)) { + let res = {} + for (const query of queries) + res = { ...res, ...q(query) } + return res + } else + return q(queries) + } + + // return the value for a single query if found, otherwise returns undefined + get (query) { + const obj = this.query(query) + if (obj) + return obj[query] + } + + // creates objects along the way for the provided `query` parameter + // and assigns `value` to the last property of the query chain + set (query, value, { force } = {}) { + setter({ + data: this[_data], + key: query, + value, + force, + }) + } + + // deletes the value of the property found at `query` + delete (query) { + setter({ + data: this[_data], + key: query, + value: _delete, + }) + } + + toJSON () { + return this[_data] + } + + [util.inspect.custom] () { + return this.toJSON() + } +} + +module.exports = Queryable diff --git a/lib/view.js b/lib/view.js index 788df3ed0..47e631f55 100644 --- a/lib/view.js +++ b/lib/view.js @@ -17,6 +17,7 @@ const { packument } = require('pacote') const readFile = promisify(fs.readFile) const readJson = async file => jsonParse(await readFile(file, 'utf8')) +const Queryable = require('./utils/queryable.js') const BaseCommand = require('./base-command.js') class View extends BaseCommand { /* istanbul ignore next - see test/lib/load-all-commands.js */ @@ -459,56 +460,13 @@ function showFields (data, version, fields) { o[k] = s[k] }) }) - return search(o, fields.split('.'), version.version, fields) -} -function search (data, fields, version, title) { - let field - const tail = fields - while (!field && fields.length) - field = tail.shift() - fields = [field].concat(tail) - let o - if (!field && !tail.length) { - o = {} - o[version] = {} - o[version][title] = data - return o - } - let index = field.match(/(.+)\[([^\]]+)\]$/) - if (index) { - field = index[1] - index = index[2] - if (data[field] && data[field][index]) - return search(data[field][index], tail, version, title) - else - field = field + '[' + index + ']' - } - if (Array.isArray(data)) { - if (data.length === 1) - return search(data[0], fields, version, title) - - let results = [] - data.forEach((data, i) => { - const tl = title.length - const newt = title.substr(0, tl - fields.join('.').length - 1) + - '[' + i + ']' + [''].concat(fields).join('.') - results.push(search(data, fields.slice(), version, newt)) - }) - results = results.reduce(reducer, {}) - return results - } - if (!data[field]) - return undefined - data = data[field] - if (tail.length) { - // there are more fields to deal with. - return search(data, tail, version, title) - } - o = {} - o[version] = {} - o[version][title] = data - return o + const queryable = new Queryable(o) + const s = queryable.query(fields) + const res = { [version.version]: s } + + if (s) + return res } function cleanup (data) { diff --git a/smoke-tests/index.js b/smoke-tests/index.js index c7b2d2a1c..9235c8960 100644 --- a/smoke-tests/index.js +++ b/smoke-tests/index.js @@ -209,3 +209,35 @@ t.test('npm uninstall', async t => { 'should have expected uninstall lockfile result' ) }) + +t.test('npm pkg', async t => { + let cmd = `${npmBin} pkg get license` + let cmdRes = await exec(cmd) + t.matchSnapshot(cmdRes.replace(/in.*s/, ''), + 'should have expected pkg get output') + + cmd = `${npmBin} pkg set tap[test-env][0]=LC_ALL=sk` + cmdRes = await exec(cmd) + t.matchSnapshot(cmdRes.replace(/in.*s/, ''), + 'should have expected pkg set output') + + t.matchSnapshot( + readFile('package.json'), + 'should have expected npm pkg set modified package.json result' + ) + + cmd = `${npmBin} pkg get` + cmdRes = await exec(cmd) + t.matchSnapshot(cmdRes.replace(/in.*s/, ''), + 'should print package.json contents') + + cmd = `${npmBin} pkg delete tap` + cmdRes = await exec(cmd) + t.matchSnapshot(cmdRes.replace(/in.*s/, ''), + 'should have expected pkg delete output') + + t.matchSnapshot( + readFile('package.json'), + 'should have expected npm pkg delete modified package.json result' + ) +}) diff --git a/tap-snapshots/smoke-tests/index.js.test.cjs b/tap-snapshots/smoke-tests/index.js.test.cjs index 89c0cb20b..0a79e38cd 100644 --- a/tap-snapshots/smoke-tests/index.js.test.cjs +++ b/tap-snapshots/smoke-tests/index.js.test.cjs @@ -26,10 +26,10 @@ All commands: edit, exec, explain, explore, find-dupes, fund, get, help, hook, init, install, install-ci-test, install-test, link, ll, login, logout, ls, org, outdated, owner, pack, ping, - prefix, profile, prune, publish, rebuild, repo, restart, - root, run-script, search, set, set-script, shrinkwrap, star, - stars, start, stop, team, test, token, uninstall, unpublish, - unstar, update, version, view, whoami + pkg, prefix, profile, prune, publish, rebuild, repo, + restart, root, run-script, search, set, set-script, + shrinkwrap, star, stars, start, stop, team, test, token, + uninstall, unpublish, unstar, update, version, view, whoami Specify configs in the ini-formatted file: {CWD}/smoke-tests/tap-testdir-index/.npmrc @@ -482,6 +482,89 @@ abbrev 1.0.4 1.1.1 1.1.1 node_modules/abbrev project ` +exports[`smoke-tests/index.js TAP npm pkg > should have expected npm pkg delete modified package.json result 1`] = ` +{ + "name": "project", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo /"Error: no test specified/" && exit 1", + "hello": "echo Hello" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "abbrev": "^1.0.4" + } +} + +` + +exports[`smoke-tests/index.js TAP npm pkg > should have expected npm pkg set modified package.json result 1`] = ` +{ + "name": "project", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo /"Error: no test specified/" && exit 1", + "hello": "echo Hello" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "abbrev": "^1.0.4" + }, + "tap": { + "test-env": [ + "LC_ALL=sk" + ] + } +} + +` + +exports[`smoke-tests/index.js TAP npm pkg > should have expected pkg delete output 1`] = ` + +` + +exports[`smoke-tests/index.js TAP npm pkg > should have expected pkg get output 1`] = ` +"ISC" + +` + +exports[`smoke-tests/index.js TAP npm pkg > should have expected pkg set output 1`] = ` + +` + +exports[`smoke-tests/index.js TAP npm pkg > should print package.json contents 1`] = ` +{ + "name": "project", + "version": "1.0.0", + "description": "", + "ma", + "scripts": { + "test": "echo /"Error: no test specified/" && exit 1", + "hello": "echo Hello" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "abbrev": "^1.0.4" + }, + "tap": { + "test-env": [ + "LC_ALL=sk" + ] + } +} + +` + exports[`smoke-tests/index.js TAP npm prefix > should have expected prefix output 1`] = ` {CWD}/smoke-tests/tap-testdir-index/project diff --git a/tap-snapshots/test/lib/load-all-commands.js.test.cjs b/tap-snapshots/test/lib/load-all-commands.js.test.cjs index 3575783a6..3b0754c52 100644 --- a/tap-snapshots/test/lib/load-all-commands.js.test.cjs +++ b/tap-snapshots/test/lib/load-all-commands.js.test.cjs @@ -678,6 +678,24 @@ Options: Run "npm help ping" for more info ` +exports[`test/lib/load-all-commands.js TAP load each command pkg > must match snapshot 1`] = ` +npm pkg + +Manages your package.json + +Usage: +npm pkg set <key>=<value> [<key>=<value> ...] +npm pkg get [<key> [<key> ...]] +npm pkg delete <key> [<key> ...] + +Options: +[-f|--force] [--json] +[-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]] +[-ws|--workspaces] + +Run "npm help pkg" for more info +` + exports[`test/lib/load-all-commands.js TAP load each command prefix > must match snapshot 1`] = ` npm prefix diff --git a/tap-snapshots/test/lib/utils/cmd-list.js.test.cjs b/tap-snapshots/test/lib/utils/cmd-list.js.test.cjs index 832f85601..971580792 100644 --- a/tap-snapshots/test/lib/utils/cmd-list.js.test.cjs +++ b/tap-snapshots/test/lib/utils/cmd-list.js.test.cjs @@ -158,6 +158,7 @@ Object { "diff", "dist-tag", "ping", + "pkg", "test", "stop", "start", diff --git a/tap-snapshots/test/lib/utils/config/definitions.js.test.cjs b/tap-snapshots/test/lib/utils/config/definitions.js.test.cjs index 12df9ec89..7b2ffbd8d 100644 --- a/tap-snapshots/test/lib/utils/config/definitions.js.test.cjs +++ b/tap-snapshots/test/lib/utils/config/definitions.js.test.cjs @@ -648,6 +648,7 @@ mistakes, unnecessary performance degradation, and malicious input. * Allow unpublishing all versions of a published package. * Allow conflicting peerDependencies to be installed in the root project. * Implicitly set \`--yes\` during \`npm init\`. +* Allow clobbering existing values in \`npm pkg\` If you don't have a clear idea of what you want to do, it is strongly recommended that you do not use this option! @@ -949,6 +950,9 @@ exports[`test/lib/utils/config/definitions.js TAP > config description for json Whether or not to output JSON data, rather than the normal output. +* In \`npm pkg set\` it enables parsing set values with JSON.parse() before + saving them to your \`package.json\`. + Not supported by all npm commands. ` diff --git a/tap-snapshots/test/lib/utils/config/describe-all.js.test.cjs b/tap-snapshots/test/lib/utils/config/describe-all.js.test.cjs index daa071b64..babed3253 100644 --- a/tap-snapshots/test/lib/utils/config/describe-all.js.test.cjs +++ b/tap-snapshots/test/lib/utils/config/describe-all.js.test.cjs @@ -374,6 +374,7 @@ mistakes, unnecessary performance degradation, and malicious input. * Allow unpublishing all versions of a published package. * Allow conflicting peerDependencies to be installed in the root project. * Implicitly set \`--yes\` during \`npm init\`. +* Allow clobbering existing values in \`npm pkg\` If you don't have a clear idea of what you want to do, it is strongly recommended that you do not use this option! @@ -573,6 +574,9 @@ number, if not already set in package.json. Whether or not to output JSON data, rather than the normal output. +* In \`npm pkg set\` it enables parsing set values with JSON.parse() before + saving them to your \`package.json\`. + Not supported by all npm commands. #### \`key\` diff --git a/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs b/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs index 3987f6a73..f417d41b6 100644 --- a/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs +++ b/tap-snapshots/test/lib/utils/npm-usage.js.test.cjs @@ -26,10 +26,10 @@ All commands: edit, exec, explain, explore, find-dupes, fund, get, help, hook, init, install, install-ci-test, install-test, link, ll, login, logout, ls, org, outdated, owner, pack, ping, - prefix, profile, prune, publish, rebuild, repo, restart, - root, run-script, search, set, set-script, shrinkwrap, star, - stars, start, stop, team, test, token, uninstall, unpublish, - unstar, update, version, view, whoami + pkg, prefix, profile, prune, publish, rebuild, repo, + restart, root, run-script, search, set, set-script, + shrinkwrap, star, stars, start, stop, team, test, token, + uninstall, unpublish, unstar, update, version, view, whoami Specify configs in the ini-formatted file: /some/config/file/.npmrc @@ -62,10 +62,10 @@ All commands: edit, exec, explain, explore, find-dupes, fund, get, help, hook, init, install, install-ci-test, install-test, link, ll, login, logout, ls, org, outdated, owner, pack, ping, - prefix, profile, prune, publish, rebuild, repo, restart, - root, run-script, search, set, set-script, shrinkwrap, star, - stars, start, stop, team, test, token, uninstall, unpublish, - unstar, update, version, view, whoami + pkg, prefix, profile, prune, publish, rebuild, repo, + restart, root, run-script, search, set, set-script, + shrinkwrap, star, stars, start, stop, team, test, token, + uninstall, unpublish, unstar, update, version, view, whoami Specify configs in the ini-formatted file: /some/config/file/.npmrc @@ -98,10 +98,10 @@ All commands: edit, exec, explain, explore, find-dupes, fund, get, help, hook, init, install, install-ci-test, install-test, link, ll, login, logout, ls, org, outdated, owner, pack, ping, - prefix, profile, prune, publish, rebuild, repo, restart, - root, run-script, search, set, set-script, shrinkwrap, star, - stars, start, stop, team, test, token, uninstall, unpublish, - unstar, update, version, view, whoami + pkg, prefix, profile, prune, publish, rebuild, repo, + restart, root, run-script, search, set, set-script, + shrinkwrap, star, stars, start, stop, team, test, token, + uninstall, unpublish, unstar, update, version, view, whoami Specify configs in the ini-formatted file: /some/config/file/.npmrc @@ -134,10 +134,10 @@ All commands: edit, exec, explain, explore, find-dupes, fund, get, help, hook, init, install, install-ci-test, install-test, link, ll, login, logout, ls, org, outdated, owner, pack, ping, - prefix, profile, prune, publish, rebuild, repo, restart, - root, run-script, search, set, set-script, shrinkwrap, star, - stars, start, stop, team, test, token, uninstall, unpublish, - unstar, update, version, view, whoami + pkg, prefix, profile, prune, publish, rebuild, repo, + restart, root, run-script, search, set, set-script, + shrinkwrap, star, stars, start, stop, team, test, token, + uninstall, unpublish, unstar, update, version, view, whoami Specify configs in the ini-formatted file: /some/config/file/.npmrc @@ -762,6 +762,22 @@ All commands: Run "npm help ping" for more info + pkg npm pkg + + Manages your package.json + + Usage: + npm pkg set <key>=<value> [<key>=<value> ...] + npm pkg get [<key> [<key> ...]] + npm pkg delete <key> [<key> ...] + + Options: + [-f|--force] [--json] + [-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]] + [-ws|--workspaces] + + Run "npm help pkg" for more info + prefix npm prefix Display prefix diff --git a/test/lib/pkg.js b/test/lib/pkg.js new file mode 100644 index 000000000..42eb7c0cc --- /dev/null +++ b/test/lib/pkg.js @@ -0,0 +1,705 @@ +const { resolve } = require('path') +const { readFileSync } = require('fs') +const t = require('tap') +const { fake: mockNpm } = require('../fixtures/mock-npm') + +const redactCwd = (path) => { + const normalizePath = p => p + .replace(/\\+/g, '/') + .replace(/\r\n/g, '\n') + return normalizePath(path) + .replace(new RegExp(normalizePath(process.cwd()), 'g'), '{CWD}') +} + +t.cleanSnapshot = (str) => redactCwd(str) + +let OUTPUT = '' +const config = { + global: false, + force: false, + 'pkg-cast': 'string', +} +const npm = mockNpm({ + localPrefix: t.testdirName, + config, + output: (str) => { + OUTPUT += str + }, +}) + +const Pkg = require('../../lib/pkg.js') +const pkg = new Pkg(npm) + +const readPackageJson = (path) => { + path = path || npm.localPrefix + return JSON.parse(readFileSync(resolve(path, 'package.json'), 'utf8')) +} + +t.afterEach(() => { + config.global = false + config.json = false + npm.localPrefix = t.testdirName + OUTPUT = '' +}) + +t.test('no args', t => { + pkg.exec([], err => { + t.match( + err, + { code: 'EUSAGE' }, + 'should throw usage error' + ) + t.end() + }) +}) + +t.test('no global mode', t => { + config.global = true + pkg.exec(['get', 'foo'], err => { + t.match( + err, + { code: 'EPKGGLOBAL' }, + 'should throw no global mode error' + ) + t.end() + }) +}) + +t.test('get no args', t => { + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.1.1', + }), + }) + + pkg.exec(['get'], err => { + if (err) + throw err + + t.strictSame( + JSON.parse(OUTPUT), + { + name: 'foo', + version: '1.1.1', + }, + 'should print package.json content' + ) + t.end() + }) +}) + +t.test('get single arg', t => { + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.1.1', + }), + }) + + pkg.exec(['get', 'version'], err => { + if (err) + throw err + + t.strictSame( + JSON.parse(OUTPUT), + '1.1.1', + 'should print retrieved package.json field' + ) + t.end() + }) +}) + +t.test('get nested arg', t => { + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.1.1', + scripts: { + test: 'node test.js', + }, + }), + }) + + pkg.exec(['get', 'scripts.test'], err => { + if (err) + throw err + + t.strictSame( + JSON.parse(OUTPUT), + 'node test.js', + 'should print retrieved nested field' + ) + t.end() + }) +}) + +t.test('get array field', t => { + const files = [ + 'index.js', + 'cli.js', + ] + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.1.1', + files, + }), + }) + + pkg.exec(['get', 'files'], err => { + if (err) + throw err + + t.strictSame( + JSON.parse(OUTPUT), + files, + 'should print retrieved array field' + ) + t.end() + }) +}) + +t.test('get array item', t => { + const files = [ + 'index.js', + 'cli.js', + ] + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.1.1', + files, + }), + }) + + pkg.exec(['get', 'files[0]'], err => { + if (err) + throw err + + t.strictSame( + JSON.parse(OUTPUT), + 'index.js', + 'should print retrieved array field' + ) + t.end() + }) +}) + +t.test('get array nested items notation', t => { + const contributors = [ + { + name: 'Ruy', + url: 'http://example.com/ruy', + }, + { + name: 'Gar', + url: 'http://example.com/gar', + }, + ] + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.1.1', + contributors, + }), + }) + + pkg.exec(['get', 'contributors.name'], err => { + if (err) + throw err + + t.strictSame( + JSON.parse(OUTPUT), + { + 'contributors[0].name': 'Ruy', + 'contributors[1].name': 'Gar', + }, + 'should print json result containing matching results' + ) + t.end() + }) +}) + +t.test('set no args', t => { + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ name: 'foo' }), + }) + pkg.exec(['set'], err => { + t.match( + err, + { code: 'EPKGSET' }, + 'should throw an error if no args' + ) + + t.end() + }) +}) + +t.test('set missing value', t => { + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ name: 'foo' }), + }) + pkg.exec(['set', 'key='], err => { + t.match( + err, + { code: 'EPKGSET' }, + 'should throw an error if missing value' + ) + + t.end() + }) +}) + +t.test('set missing key', t => { + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ name: 'foo' }), + }) + pkg.exec(['set', '=value'], err => { + t.match( + err, + { code: 'EPKGSET' }, + 'should throw an error if missing key' + ) + + t.end() + }) +}) + +t.test('set single field', t => { + const json = { + name: 'foo', + version: '1.1.1', + } + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify(json), + }) + + pkg.exec(['set', 'description=Awesome stuff'], err => { + if (err) + throw err + + t.strictSame( + readPackageJson(), + { + ...json, + description: 'Awesome stuff', + }, + 'should add single field to package.json' + ) + t.end() + }) +}) + +t.test('set multiple fields', t => { + const json = { + name: 'foo', + version: '1.1.1', + } + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify(json), + }) + + pkg.exec(['set', 'bin.foo=foo.js', 'scripts.test=node test.js'], err => { + if (err) + throw err + + t.strictSame( + readPackageJson(), + { + ...json, + bin: { + foo: 'foo.js', + }, + scripts: { + test: 'node test.js', + }, + }, + 'should add single field to package.json' + ) + t.end() + }) +}) + +t.test('set = separate value', t => { + const json = { + name: 'foo', + version: '1.1.1', + } + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify(json), + }) + + pkg.exec(['set', 'tap[test-env][0]=LC_ALL=sk'], err => { + if (err) + throw err + + t.strictSame( + readPackageJson(), + { + ...json, + tap: { + 'test-env': [ + 'LC_ALL=sk', + ], + }, + }, + 'should add single field to package.json' + ) + t.end() + }) +}) + +t.test('set --json', async t => { + config.json = true + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.1.1', + }), + }) + + await new Promise((res, rej) => { + pkg.exec(['set', 'private=true'], err => { + if (err) + rej(err) + + t.strictSame( + readPackageJson(), + { + name: 'foo', + version: '1.1.1', + private: true, + }, + 'should add boolean field to package.json' + ) + res() + }) + }) + + await new Promise((res, rej) => { + pkg.exec(['set', 'tap.timeout=60'], err => { + if (err) + rej(err) + + t.strictSame( + readPackageJson(), + { + name: 'foo', + version: '1.1.1', + private: true, + tap: { + timeout: 60, + }, + }, + 'should add number field to package.json' + ) + res() + }) + }) + + await new Promise((res, rej) => { + pkg.exec(['set', 'foo={ "bar": { "baz": "BAZ" } }'], err => { + if (err) + rej(err) + + t.strictSame( + readPackageJson(), + { + name: 'foo', + version: '1.1.1', + private: true, + tap: { + timeout: 60, + }, + foo: { + bar: { + baz: 'BAZ', + }, + }, + }, + 'should add object field to package.json' + ) + res() + }) + }) + + await new Promise((res, rej) => { + pkg.exec(['set', 'workspaces=["packages/*"]'], err => { + if (err) + rej(err) + + t.strictSame( + readPackageJson(), + { + name: 'foo', + version: '1.1.1', + private: true, + workspaces: [ + 'packages/*', + ], + tap: { + timeout: 60, + }, + foo: { + bar: { + baz: 'BAZ', + }, + }, + }, + 'should add object field to package.json' + ) + res() + }) + }) + + await new Promise((res, rej) => { + pkg.exec(['set', 'description="awesome"'], err => { + if (err) + rej(err) + + t.strictSame( + readPackageJson(), + { + name: 'foo', + version: '1.1.1', + description: 'awesome', + private: true, + workspaces: [ + 'packages/*', + ], + tap: { + timeout: 60, + }, + foo: { + bar: { + baz: 'BAZ', + }, + }, + }, + 'should add object field to package.json' + ) + res() + }) + }) +}) + +t.test('delete no args', t => { + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ name: 'foo' }), + }) + pkg.exec(['delete'], err => { + t.match( + err, + { code: 'EPKGDELETE' }, + 'should throw an error if deleting no args' + ) + + t.end() + }) +}) + +t.test('delete invalid key', t => { + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ name: 'foo' }), + }) + pkg.exec(['delete', ''], err => { + t.match( + err, + { code: 'EPKGDELETE' }, + 'should throw an error if deleting invalid args' + ) + + t.end() + }) +}) + +t.test('delete single field', t => { + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.0.0', + }), + }) + pkg.exec(['delete', 'version'], err => { + if (err) + throw err + + t.strictSame( + readPackageJson(), + { + name: 'foo', + }, + 'should delete single field from package.json' + ) + + t.end() + }) +}) + +t.test('delete multiple field', t => { + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.0.0', + description: 'awesome', + }), + }) + pkg.exec(['delete', 'version', 'description'], err => { + if (err) + throw err + + t.strictSame( + readPackageJson(), + { + name: 'foo', + }, + 'should delete multiple fields from package.json' + ) + + t.end() + }) +}) + +t.test('delete nested field', t => { + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.0.0', + info: { + foo: { + bar: [ + { + baz: 'deleteme', + }, + ], + }, + }, + }), + }) + pkg.exec(['delete', 'info.foo.bar[0].baz'], err => { + if (err) + throw err + + t.strictSame( + readPackageJson(), + { + name: 'foo', + version: '1.0.0', + info: { + foo: { + bar: [ + {}, + ], + }, + }, + }, + 'should delete nested fields from package.json' + ) + + t.end() + }) +}) + +t.test('workspaces', async t => { + npm.localPrefix = t.testdir({ + 'package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + workspaces: [ + 'packages/*', + ], + }), + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + version: '1.0.0', + }), + }, + b: { + 'package.json': JSON.stringify({ + name: 'b', + version: '1.2.3', + }), + }, + }, + }) + + await new Promise((res, rej) => { + pkg.execWorkspaces(['get', 'name', 'version'], [], err => { + if (err) + rej(err) + + t.strictSame( + JSON.parse(OUTPUT), + { + a: { + name: 'a', + version: '1.0.0', + }, + b: { + name: 'b', + version: '1.2.3', + }, + }, + 'should return expected result for configured workspaces' + ) + res() + }) + }) + + await new Promise((res, rej) => { + pkg.execWorkspaces(['set', 'funding=http://example.com'], [], err => { + if (err) + rej(err) + + t.strictSame( + readPackageJson(resolve(npm.localPrefix, 'packages/a')), + { + name: 'a', + version: '1.0.0', + funding: 'http://example.com', + }, + 'should add field to workspace a' + ) + + t.strictSame( + readPackageJson(resolve(npm.localPrefix, 'packages/b')), + { + name: 'b', + version: '1.2.3', + funding: 'http://example.com', + }, + 'should add field to workspace b' + ) + res() + }) + }) + + await new Promise((res, rej) => { + pkg.execWorkspaces(['delete', 'version'], [], err => { + if (err) + rej(err) + + t.strictSame( + readPackageJson(resolve(npm.localPrefix, 'packages/a')), + { + name: 'a', + funding: 'http://example.com', + }, + 'should delete version field from workspace a' + ) + + t.strictSame( + readPackageJson(resolve(npm.localPrefix, 'packages/b')), + { + name: 'b', + funding: 'http://example.com', + }, + 'should delete version field from workspace b' + ) + res() + }) + }) +}) diff --git a/test/lib/utils/queryable.js b/test/lib/utils/queryable.js new file mode 100644 index 000000000..2e66eeeb9 --- /dev/null +++ b/test/lib/utils/queryable.js @@ -0,0 +1,705 @@ +const { inspect } = require('util') +const t = require('tap') +const Queryable = require('../../../lib/utils/queryable.js') + +t.test('retrieve single nested property', async t => { + const fixture = { + foo: { + bar: 'bar', + baz: 'baz', + }, + lorem: { + ipsum: 'ipsum', + }, + } + const q = new Queryable(fixture) + const query = 'foo.bar' + t.strictSame(q.query(query), { [query]: 'bar' }, + 'should retrieve property value when querying for dot-sep name') +}) + +t.test('query', async t => { + const fixture = { + o: 'o', + single: [ + 'item', + ], + w: [ + 'a', + 'b', + 'c', + ], + list: [ + { + name: 'first', + }, + { + name: 'second', + }, + ], + foo: { + bar: 'bar', + baz: 'baz', + }, + lorem: { + ipsum: 'ipsum', + dolor: [ + 'a', + 'b', + 'c', + { + sit: [ + 'amet', + ], + }, + ], + }, + a: [ + [ + [ + { + b: [ + [ + { + c: 'd', + }, + ], + ], + }, + ], + ], + ], + } + const q = new Queryable(fixture) + t.strictSame( + q.query(['foo.baz', 'lorem.dolor[0]']), + { + 'foo.baz': 'baz', + 'lorem.dolor[0]': 'a', + }, + 'should retrieve property values when querying for multiple dot-sep names') + t.strictSame( + q.query('lorem.dolor[3].sit[0]'), + { + 'lorem.dolor[3].sit[0]': 'amet', + }, + 'should retrieve property from nested array items') + t.strictSame( + q.query('a[0][0][0].b[0][0].c'), + { + 'a[0][0][0].b[0][0].c': 'd', + }, + 'should retrieve property from deep nested array items') + t.strictSame( + q.query('o'), + { + o: 'o', + }, + 'should retrieve single level property value') + t.strictSame( + q.query('list.name'), + { + 'list[0].name': 'first', + 'list[1].name': 'second', + }, + 'should automatically expand arrays') + t.strictSame( + q.query(['list.name']), + { + 'list[0].name': 'first', + 'list[1].name': 'second', + }, + 'should automatically expand multiple arrays') + t.strictSame( + q.query('w'), + { + w: ['a', 'b', 'c'], + }, + 'should return arrays') + t.strictSame( + q.query('single'), + { + single: 'item', + }, + 'should return single item') + t.strictSame( + q.query('missing'), + undefined, + 'should return undefined') + t.strictSame( + q.query('missing[bar]'), + undefined, + 'should return undefined also') + + const qq = new Queryable({ + foo: { + bar: 'bar', + }, + }) + t.strictSame( + qq.query(''), + { + '': { + foo: { + bar: 'bar', + }, + }, + }, + 'should return an object with results in an empty key' + ) +}) + +t.test('missing key', async t => { + const fixture = { + foo: { + bar: 'bar', + }, + } + const q = new Queryable(fixture) + const query = 'foo.missing' + t.equal(q.query(query), undefined, + 'should retrieve no results') +}) + +t.test('no data object', async t => { + t.throws( + () => new Queryable(), + { code: 'ENOQUERYABLEOBJ' }, + 'should throw ENOQUERYABLEOBJ error' + ) + t.throws( + () => new Queryable(1), + { code: 'ENOQUERYABLEOBJ' }, + 'should throw ENOQUERYABLEOBJ error' + ) +}) + +t.test('get values', async t => { + const q = new Queryable({ + foo: { + bar: 'bar', + }, + }) + t.equal(q.get('foo.bar'), 'bar', 'should retrieve value') + t.equal(q.get('missing'), undefined, 'should return undefined') +}) + +t.test('set property values', async t => { + const fixture = { + foo: { + bar: 'bar', + }, + } + const q = new Queryable(fixture) + q.set('foo.baz', 'baz') + t.strictSame( + q.toJSON(), + { + foo: { + bar: 'bar', + baz: 'baz', + }, + }, + 'should add new property and its assigned value' + ) + q.set('foo[lorem.ipsum]', 'LOREM IPSUM') + t.strictSame( + q.toJSON(), + { + foo: { + bar: 'bar', + baz: 'baz', + 'lorem.ipsum': 'LOREM IPSUM', + }, + }, + 'should be able to set square brackets props' + ) + q.set('a.b[c.d]', 'omg') + t.strictSame( + q.toJSON(), + { + foo: { + bar: 'bar', + baz: 'baz', + 'lorem.ipsum': 'LOREM IPSUM', + }, + a: { + b: { + 'c.d': 'omg', + }, + }, + }, + 'should be able to nest square brackets props' + ) + q.set('a.b[e][f.g][1.0.0]', 'multiple') + t.strictSame( + q.toJSON(), + { + foo: { + bar: 'bar', + baz: 'baz', + 'lorem.ipsum': 'LOREM IPSUM', + }, + a: { + b: { + 'c.d': 'omg', + e: { + 'f.g': { + '1.0.0': 'multiple', + }, + }, + }, + }, + }, + 'should be able to nest multiple square brackets props' + ) + q.set('a.b[e][f.g][2.0.0].author.name', 'Ruy Adorno') + t.strictSame( + q.toJSON(), + { + foo: { + bar: 'bar', + baz: 'baz', + 'lorem.ipsum': 'LOREM IPSUM', + }, + a: { + b: { + 'c.d': 'omg', + e: { + 'f.g': { + '1.0.0': 'multiple', + '2.0.0': { + author: { + name: 'Ruy Adorno', + }, + }, + }, + }, + }, + }, + }, + 'should be able to use dot-sep notation after square bracket props' + ) + q.set('a.b[e][f.g][2.0.0].author[url]', 'https://npmjs.com') + t.strictSame( + q.toJSON(), + { + foo: { + bar: 'bar', + baz: 'baz', + 'lorem.ipsum': 'LOREM IPSUM', + }, + a: { + b: { + 'c.d': 'omg', + e: { + 'f.g': { + '1.0.0': 'multiple', + '2.0.0': { + author: { + name: 'Ruy Adorno', + url: 'https://npmjs.com', + }, + }, + }, + }, + }, + }, + }, + 'should be able to have multiple, separated, square brackets props' + ) + q.set('a.b[e][f.g][2.0.0].author[foo][bar].lorem.ipsum[dolor][sit][amet].omg', 'O_O') + t.strictSame( + q.toJSON(), + { + foo: { + bar: 'bar', + baz: 'baz', + 'lorem.ipsum': 'LOREM IPSUM', + }, + a: { + b: { + 'c.d': 'omg', + e: { + 'f.g': { + '1.0.0': 'multiple', + '2.0.0': { + author: { + name: 'Ruy Adorno', + url: 'https://npmjs.com', + foo: { + bar: { + lorem: { + ipsum: { + dolor: { + sit: { + amet: { + omg: 'O_O', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + 'many many times...' + ) + t.throws( + () => q.set('foo.bar.nest', 'should throw'), + { code: 'EOVERRIDEVALUE' }, + 'should throw if trying to override a literal value with an object' + ) + q.set('foo.bar.nest', 'use the force!', { force: true }) + t.strictSame( + q.toJSON().foo, + { + bar: { + nest: 'use the force!', + }, + baz: 'baz', + 'lorem.ipsum': 'LOREM IPSUM', + }, + 'should allow overriding literal values when using force option' + ) + + const qq = new Queryable({}) + qq.set('foo.bar.baz', 'BAZ') + t.strictSame( + qq.toJSON(), + { + foo: { + bar: { + baz: 'BAZ', + }, + }, + }, + 'should add new props to qq object' + ) + qq.set('foo.bar.bario', 'bario') + t.strictSame( + qq.toJSON(), + { + foo: { + bar: { + baz: 'BAZ', + bario: 'bario', + }, + }, + }, + 'should add new props to a previously existing object' + ) + qq.set('lorem', 'lorem') + t.strictSame( + qq.toJSON(), + { + foo: { + bar: { + baz: 'BAZ', + bario: 'bario', + }, + }, + lorem: 'lorem', + }, + 'should append new props added to object later' + ) + qq.set('foo.bar[foo.bar]', 'foo.bar.with.dots') + t.strictSame( + qq.toJSON(), + { + foo: { + bar: { + 'foo.bar': 'foo.bar.with.dots', + baz: 'BAZ', + bario: 'bario', + }, + }, + lorem: 'lorem', + }, + 'should append new props added to object later' + ) +}) + +t.test('set arrays', async t => { + const q = new Queryable({}) + + q.set('foo[1]', 'b') + t.strictSame( + q.toJSON(), + { + foo: [ + undefined, + 'b', + ], + }, + 'should be able to set items in an array using index references' + ) + + q.set('foo[0]', 'a') + t.strictSame( + q.toJSON(), + { + foo: [ + 'a', + 'b', + ], + }, + 'should be able to set a previously missing item to an array' + ) + + q.set('foo[2]', 'c') + t.strictSame( + q.toJSON(), + { + foo: [ + 'a', + 'b', + 'c', + ], + }, + 'should be able to append more items to an array' + ) + + q.set('foo[2]', 'C') + t.strictSame( + q.toJSON(), + { + foo: [ + 'a', + 'b', + 'C', + ], + }, + 'should be able to override array items' + ) + + t.throws( + () => q.set('foo[2].bar', 'bar'), + { code: 'EOVERRIDEVALUE' }, + 'should throw if trying to override an array literal item with an obj' + ) + + q.set('foo[2].bar', 'bar', { force: true }) + t.strictSame( + q.toJSON(), + { + foo: [ + 'a', + 'b', + { bar: 'bar' }, + ], + }, + 'should be able to override an array string item with an obj' + ) + + q.set('foo[3].foo', 'surprise surprise, another foo') + t.strictSame( + q.toJSON(), + { + foo: [ + 'a', + 'b', + { bar: 'bar' }, + { + foo: 'surprise surprise, another foo', + }, + ], + }, + 'should be able to append more items to an array' + ) + + q.set('foo[3].foo', 'FOO') + t.strictSame( + q.toJSON(), + { + foo: [ + 'a', + 'b', + { bar: 'bar' }, + { + foo: 'FOO', + }, + ], + }, + 'should be able to override property of an obj inside an array' + ) + + const qq = new Queryable({}) + qq.set('foo[0].bar[1].baz.bario[0][0][0]', 'something') + t.strictSame( + qq.toJSON(), + { + foo: [ + { + bar: [ + undefined, + { + baz: { + bario: [[['something']]], + }, + }, + ], + }, + ], + }, + 'should append as many arrays as necessary' + ) + qq.set('foo[0].bar[1].baz.bario[0][1][0]', 'something else') + t.strictSame( + qq.toJSON(), + { + foo: [ + { + bar: [ + undefined, + { + baz: { + bario: [[ + ['something'], + ['something else'], + ]], + }, + }, + ], + }, + ], + }, + 'should append as many arrays as necessary' + ) + qq.set('foo', null) + t.strictSame( + qq.toJSON(), + { + foo: null, + }, + 'should be able to set a value to null' + ) + qq.set('foo.bar', 'bar') + t.strictSame( + qq.toJSON(), + { + foo: { + bar: 'bar', + }, + }, + 'should be able to replace a null value with properties' + ) + + const qqq = new Queryable({ + arr: [ + 'a', + 'b', + ], + }) + t.throws( + () => qqq.set('arr.foo', 'foo'), + { code: 'EOVERRIDEVALUE' }, + 'should throw an override error' + ) +}) + +t.test('delete values', async t => { + const q = new Queryable({ + foo: { + bar: { + lorem: 'lorem', + }, + }, + }) + q.delete('foo.bar.lorem') + t.strictSame( + q.toJSON(), + { + foo: { + bar: {}, + }, + }, + 'should delete queried item' + ) + q.delete('foo') + t.strictSame( + q.toJSON(), + {}, + 'should delete nested items' + ) + q.set('foo.a.b.c[0]', 'value') + q.delete('foo.a.b.c[0]') + t.strictSame( + q.toJSON(), + { + foo: { + a: { + b: { + c: [], + }, + }, + }, + }, + 'should delete array item' + ) + // creates an array that has an implicit empty first item + q.set('foo.a.b.c[1][0].foo.bar[0][0]', 'value') + q.delete('foo.a.b.c[1]') + t.strictSame( + q.toJSON(), + { + foo: { + a: { + b: { + c: [null], + }, + }, + }, + }, + 'should delete array item' + ) +}) + +t.test('logger', async t => { + const q = new Queryable({}) + q.set('foo.bar[0].baz', 'baz') + t.strictSame( + inspect(q, { depth: 10 }), + inspect({ + foo: { + bar: [ + { + baz: 'baz', + }, + ], + }, + }, { depth: 10 }), + 'should retrieve expected data' + ) +}) + +t.test('bracket lovers', async t => { + const q = new Queryable({}) + q.set('[iLoveBrackets]', 'seriously?') + t.strictSame( + q.toJSON(), + { + '[iLoveBrackets]': 'seriously?', + }, + 'should be able to set top-level props using square brackets notation' + ) + + t.equal(q.get('[iLoveBrackets]'), 'seriously?', + 'should bypass square bracket in top-level properties') + + q.set('[0]', '-.-') + t.strictSame( + q.toJSON(), + { + '[iLoveBrackets]': 'seriously?', + '[0]': '-.-', + }, + 'any top-level item can not be parsed with square bracket notation' + ) +}) |