From 4775bf352caaaabc6b2ce252a758e61f3adeb143 Mon Sep 17 00:00:00 2001 From: Gar Date: Wed, 14 Jun 2023 11:30:52 -0700 Subject: [PATCH] feat: add create functionality --- README.md | 26 ++- lib/index.js | 46 +++- lib/normalize.js | 2 +- tap-snapshots/test/index.js.test.cjs | 38 ++-- test/index.js | 304 ++++++++++++++++----------- 5 files changed, 261 insertions(+), 155 deletions(-) diff --git a/README.md b/README.md index de858e8..0ccb54a 100644 --- a/README.md +++ b/README.md @@ -58,13 +58,25 @@ Creates a new empty instance of `PackageJson`. --- -### `async PackageJson.load(path)` +### `async PackageJson.create(path)` -Loads the `package.json` at the given path. +Creates an empty `package.json` at the given path. If one already exists +it will be overwritten. + +--- + +### `async PackageJson.load(path, opts = {})` + +Loads a `package.json` at the given path. + +- `opts`: `Object` can contain: + - `create`: `Boolean` if true, a new package.json will be created if + one does not already exist. Will not clobber ane existing + package.json that can not be parsed. ### Example: -Loads contents of the `package.json` file located at `./`: +Loads contents of a `package.json` file located at `./`: ```js const PackageJson = require('@npmcli/package-json') @@ -72,7 +84,7 @@ const pkgJson = new PackageJson() await pkgJson.load('./') ``` -Throws an error in case the `package.json` file is missing or has invalid +Throws an error in case a `package.json` file is missing or has invalid contents. --- @@ -80,14 +92,14 @@ contents. ### **static** `async PackageJson.load(path)` Convenience static method that returns a new instance and loads the contents of -the `package.json` file from that location. +a `package.json` file from that location. - `path`: `String` that points to the folder from where to read the `package.json` from ### Example: -Loads contents of the `package.json` file located at `./`: +Loads contents of a `package.json` file located at `./`: ```js const PackageJson = require('@npmcli/package-json') @@ -124,7 +136,7 @@ Convenience static that calls `load` before calling `prepare` ### `PackageJson.update(content)` -Updates the contents of the `package.json` with the `content` provided. +Updates the contents of a `package.json` with the `content` provided. - `content`: `Object` containing the properties to be updated/replaced in the `package.json` file. diff --git a/lib/index.js b/lib/index.js index b5369a8..82c0070 100644 --- a/lib/index.js +++ b/lib/index.js @@ -52,10 +52,33 @@ class PackageJson { 'binRefs', ]) - // default behavior, loads a package.json at given path and JSON parses - static async load (path) { + // create a new empty package.json, so we can save at the given path even + // though we didn't start from a parsed file + static async create (path, opts = {}) { const p = new PackageJson() - return p.load(path) + await p.create(path) + if (opts.data) { + return p.update(opts.data) + } + return p + } + + // Loads a package.json at given path and JSON parses + static async load (path, opts = {}) { + const p = new PackageJson() + // Avoid try/catch if we aren't going to create + if (!opts.create) { + return p.load(path) + } + + try { + return await p.load(path) + } catch (err) { + if (!err.message.startsWith('Could not read package.json')) { + throw err + } + return await p.create(path) + } } // read-package-json compatible behavior @@ -159,10 +182,18 @@ class PackageJson { return undefined } + create (path) { + this.#path = path + this.#manifest = {} + return this + } + + // This should be the ONLY way to set content in the manifest update (content) { if (!this.content) { - throw new Error('Can not update without existing data') + throw new Error('Can not update without content. Please `load` or `create`') } + for (const step of knownSteps) { this.#manifest = step({ content, originalContent: this.content }) } @@ -186,11 +217,8 @@ class PackageJson { [Symbol.for('newline')]: newline, } = this.content - // These can only be undefined if we didn't parse JSON, which we currently don't support. - // const format = indent === undefined ? ' ' : indent - // const eol = newline === undefined ? '\n' : newline - const format = indent - const eol = newline + const format = indent === undefined ? ' ' : indent + const eol = newline === undefined ? '\n' : newline const fileContent = `${ JSON.stringify(this.content, null, format) }\n` diff --git a/lib/normalize.js b/lib/normalize.js index 50448c2..67ac045 100644 --- a/lib/normalize.js +++ b/lib/normalize.js @@ -8,7 +8,7 @@ const git = require('@npmcli/git') const normalize = async (pkg, { strict, steps, root }) => { if (!pkg.content) { - throw new Error('Can not normalize without existing data') + throw new Error('Can not normalize without content') } const data = pkg.content const scripts = data.scripts || {} diff --git a/tap-snapshots/test/index.js.test.cjs b/tap-snapshots/test/index.js.test.cjs index da81c51..5a6ae63 100644 --- a/tap-snapshots/test/index.js.test.cjs +++ b/tap-snapshots/test/index.js.test.cjs @@ -5,31 +5,41 @@ * Make sure to inspect the output below. Do not ignore changes! */ 'use strict' -exports[`test/index.js TAP custom formatting > should save back custom format to package.json 1`] = ` -{"name":"foo","version":"1.0.1","description":"Lorem ipsum dolor"} +exports[`test/index.js TAP create with data > should save package.json with name 1`] = ` +{ + "name": "create-test" +} + ` -exports[`test/index.js TAP invalid package.json data > should save updated content to package.json and ignore invalid data 1`] = ` -this! is! not! json! +exports[`test/index.js TAP create without data > should save empty package.json 1`] = ` +{} + ` -exports[`test/index.js TAP read, update content and write > should properly save contennt to a package.json 1`] = ` -{ - "name": "foo", - "version": "1.0.1", - "description": "Lorem ipsum dolor" -} +exports[`test/index.js TAP load create:true empty dir > should save empty package.json 1`] = ` +{} + +` +exports[`test/index.js TAP load create:true existing parseable package.json > package.json should match existing 1`] = ` +{"name":"test-package"} ` -exports[`test/index.js TAP start new package.json, update and write > should properly save contentn to a package.json 1`] = ` +exports[`test/index.js TAP load custom formatting > should save back custom format to package.json 1`] = ` +{"name":"foo","version":"1.0.1","description":"Lorem ipsum dolor"} +` + +exports[`test/index.js TAP load read, update content and write > should properly save contennt to a package.json 1`] = ` { - "name": "foo" + "name": "foo", + "version": "1.0.1", + "description": "Lorem ipsum dolor" } ` -exports[`test/index.js TAP update long package.json > should only update the defined property 1`] = ` +exports[`test/index.js TAP load update long package.json > should only update the defined property 1`] = ` { "version": "7.18.1", "name": "npm", @@ -274,7 +284,7 @@ exports[`test/index.js TAP update long package.json > should only update the def ` -exports[`test/index.js TAP update long package.json > should properly write updated pacakge.json contents 1`] = ` +exports[`test/index.js TAP load update long package.json > should properly write updated pacakge.json contents 1`] = ` { "version": "7.18.1", "name": "npm", diff --git a/test/index.js b/test/index.js index cc25361..f8d1f36 100644 --- a/test/index.js +++ b/test/index.js @@ -13,140 +13,196 @@ const redactCwd = (path) => { t.cleanSnapshot = (str) => redactCwd(str) -t.test('read a valid package.json', async t => { - const path = t.testdir({ - 'package.json': JSON.stringify({ - name: 'foo', - version: '1.0.0', - }), +t.test('load', t => { + t.test('read a valid package.json', async t => { + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.0.0', + }), + }) + + const pj = await PackageJson.load(path) + t.same( + pj.content, + { name: 'foo', version: '1.0.0' }, + 'should return content for a valid package.json' + ) }) - - const pj = await PackageJson.load(path) - t.same( - pj.content, - { name: 'foo', version: '1.0.0' }, - 'should return content for a valid package.json' - ) -}) - -t.test('read, update content and write', async t => { - const path = t.testdir({ - 'package.json': JSON.stringify({ - name: 'foo', - version: '1.0.0', - }, null, 8), + t.test('read, update content and write', async t => { + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.0.0', + }, null, 8), + }) + + const pkgJson = await PackageJson.load(path) + pkgJson.update({ + version: '1.0.1', + description: 'Lorem ipsum dolor', + }) + await pkgJson.save() + + t.matchSnapshot( + fs.readFileSync(resolve(path, 'package.json'), 'utf8'), + 'should properly save contennt to a package.json' + ) }) - - const pkgJson = await PackageJson.load(path) - pkgJson.update({ - version: '1.0.1', - description: 'Lorem ipsum dolor', + t.test('read missing package.json', async t => { + const path = t.testdirName + return t.rejects( + PackageJson.load(path), + { + message: /package.json/, + code: 'ENOENT', + } + ) }) - await pkgJson.save() - - t.matchSnapshot( - fs.readFileSync(resolve(path, 'package.json'), 'utf8'), - 'should properly save contennt to a package.json' - ) -}) - -t.test('read missing package.json', async t => { - const path = t.testdirName - return t.rejects( - PackageJson.load(path), - { - message: /package.json/, - code: 'ENOENT', - } - ) -}) - -t.test('do not overwite unchanged file on EOF line added/removed', async t => { - const originalPackageJsonContent = '{\n "name": "foo"\n}' - const path = t.testdir({ - 'package.json': originalPackageJsonContent, + t.test('do not overwite unchanged file on EOF line added/removed', async t => { + const originalPackageJsonContent = '{\n "name": "foo"\n}' + const path = t.testdir({ + 'package.json': originalPackageJsonContent, + }) + + const pkgJson = await PackageJson.load(path) + await pkgJson.save() + + t.equal( + fs.readFileSync(resolve(path, 'package.json'), 'utf8'), + originalPackageJsonContent, + 'should not change file' + ) }) - - const pkgJson = await PackageJson.load(path) - await pkgJson.save() - - t.equal( - fs.readFileSync(resolve(path, 'package.json'), 'utf8'), - originalPackageJsonContent, - 'should not change file' - ) -}) - -t.test('update long package.json', async t => { - const fixture = resolve(__dirname, 'fixtures', 'package.json') - const path = t.testdir({}) - fs.copyFileSync(fixture, resolve(path, 'package.json')) - const pkgJson = await PackageJson.load(path) - - pkgJson.update({ - dependencies: { - ...pkgJson.content.dependencies, - ntl: '^5.1.0', - }, - optionalDependencies: { - ntl: '^5.1.0', - }, - devDependencies: { - ...pkgJson.content.devDependencies, - tap: '^32.0.0', - }, - scripts: { - ...pkgJson.content.scripts, - 'new-script': 'touch something', - }, - workspaces: [ - ...pkgJson.content.workspaces, - './new-workspace', - ], + t.test('update long package.json', async t => { + const fixture = resolve(__dirname, 'fixtures', 'package.json') + const path = t.testdir({}) + fs.copyFileSync(fixture, resolve(path, 'package.json')) + const pkgJson = await PackageJson.load(path) + + pkgJson.update({ + dependencies: { + ...pkgJson.content.dependencies, + ntl: '^5.1.0', + }, + optionalDependencies: { + ntl: '^5.1.0', + }, + devDependencies: { + ...pkgJson.content.devDependencies, + tap: '^32.0.0', + }, + scripts: { + ...pkgJson.content.scripts, + 'new-script': 'touch something', + }, + workspaces: [ + ...pkgJson.content.workspaces, + './new-workspace', + ], + }) + + await pkgJson.save() + + t.matchSnapshot( + fs.readFileSync(resolve(path, 'package.json'), 'utf8'), + 'should properly write updated pacakge.json contents' + ) + + // updates a single property + pkgJson.update({ + scripts: { + ...pkgJson.content.scripts, + 'new-foo': 'touch foo', + }, + }) + + await pkgJson.save() + + t.matchSnapshot( + fs.readFileSync(resolve(path, 'package.json'), 'utf8'), + 'should only update the defined property' + ) }) - - await pkgJson.save() - - t.matchSnapshot( - fs.readFileSync(resolve(path, 'package.json'), 'utf8'), - 'should properly write updated pacakge.json contents' - ) - - // updates a single property - pkgJson.update({ - scripts: { - ...pkgJson.content.scripts, - 'new-foo': 'touch foo', - }, + t.test('custom formatting', async t => { + const path = t.testdir({ + 'package.json': JSON.stringify({ + name: 'foo', + version: '1.0.0', + }, null, 0), + }) + + const pkgJson = await PackageJson.load(path) + pkgJson.update({ + version: '1.0.1', + description: 'Lorem ipsum dolor', + }) + await pkgJson.save() + + t.matchSnapshot( + fs.readFileSync(resolve(path, 'package.json'), 'utf8'), + 'should save back custom format to package.json' + ) }) - await pkgJson.save() - - t.matchSnapshot( - fs.readFileSync(resolve(path, 'package.json'), 'utf8'), - 'should only update the defined property' - ) + t.test('create:true', t => { + t.test('empty dir', async t => { + const path = t.testdir({}) + const pkgJson = await PackageJson.load(path, { create: true }) + await pkgJson.save() + t.matchSnapshot( + fs.readFileSync(resolve(path, 'package.json'), 'utf8'), + 'should save empty package.json' + ) + }) + t.test('existing parseable package.json', async t => { + const path = t.testdir({ + 'package.json': JSON.stringify({ name: 'test-package' }), + }) + const pkgJson = await PackageJson.load(path, { create: true }) + await pkgJson.save() + t.matchSnapshot( + fs.readFileSync(resolve(path, 'package.json'), 'utf8'), + 'package.json should match existing' + ) + }) + + t.test('existing unparseable package.json', async t => { + const path = t.testdir({ + 'package.json': '{this is[not json!', + }) + await t.rejects(PackageJson.load(path, { create: true }), { + message: 'Invalid package.json', + }) + }) + t.end() + }) + t.end() }) -t.test('custom formatting', async t => { - const path = t.testdir({ - 'package.json': JSON.stringify({ - name: 'foo', - version: '1.0.0', - }, null, 0), - }) +t.test('create', t => { + t.test('with data', async t => { + const path = t.testdir({}) + const pkgJson = await PackageJson.create(path, { data: { name: 'create-test' } }) + await pkgJson.save() - const pkgJson = await PackageJson.load(path) - pkgJson.update({ - version: '1.0.1', - description: 'Lorem ipsum dolor', + t.matchSnapshot( + fs.readFileSync(resolve(path, 'package.json'), 'utf8'), + 'should save package.json with name' + ) + }) + t.test('without data', async t => { + const path = t.testdir({}) + const pkgJson = await PackageJson.create(path) + await pkgJson.save() + + t.matchSnapshot( + fs.readFileSync(resolve(path, 'package.json'), 'utf8'), + 'should save empty package.json' + ) }) - await pkgJson.save() - t.matchSnapshot( - fs.readFileSync(resolve(path, 'package.json'), 'utf8'), - 'should save back custom format to package.json' - ) + t.end() }) t.test('no path means no filename', async t => { @@ -157,7 +213,7 @@ t.test('no path means no filename', async t => { t.test('cannot normalize with no content', async t => { const p = new PackageJson() await t.rejects(p.normalize(), { - message: 'Can not normalize without existing data', + message: /Can not normalize without content/, }) }) @@ -166,6 +222,6 @@ t.test('cannot update with no content', async t => { await t.throws(() => { p.update({}) }, { - message: 'Can not update without existing data', + message: /Can not update without content/, }) })