• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1const { readFile, writeFile } = require('fs/promises')
2const { resolve } = require('path')
3const updateDeps = require('./update-dependencies.js')
4const updateScripts = require('./update-scripts.js')
5const updateWorkspaces = require('./update-workspaces.js')
6const normalize = require('./normalize.js')
7
8const parseJSON = require('json-parse-even-better-errors')
9
10// a list of handy specialized helper functions that take
11// care of special cases that are handled by the npm cli
12const knownSteps = new Set([
13  updateDeps,
14  updateScripts,
15  updateWorkspaces,
16])
17
18// list of all keys that are handled by "knownSteps" helpers
19const knownKeys = new Set([
20  ...updateDeps.knownKeys,
21  'scripts',
22  'workspaces',
23])
24
25class PackageJson {
26  static normalizeSteps = Object.freeze([
27    '_id',
28    '_attributes',
29    'bundledDependencies',
30    'bundleDependencies',
31    'optionalDedupe',
32    'scripts',
33    'funding',
34    'bin',
35  ])
36
37  // npm pkg fix
38  static fixSteps = Object.freeze([
39    'binRefs',
40    'bundleDependencies',
41    'bundleDependenciesFalse',
42    'fixNameField',
43    'fixVersionField',
44    'fixRepositoryField',
45    'fixDependencies',
46    'devDependencies',
47    'scriptpath',
48  ])
49
50  static prepareSteps = Object.freeze([
51    '_id',
52    '_attributes',
53    'bundledDependencies',
54    'bundleDependencies',
55    'bundleDependenciesDeleteFalse',
56    'gypfile',
57    'serverjs',
58    'scriptpath',
59    'authors',
60    'readme',
61    'mans',
62    'binDir',
63    'gitHead',
64    'fillTypes',
65    'normalizeData',
66    'binRefs',
67  ])
68
69  // create a new empty package.json, so we can save at the given path even
70  // though we didn't start from a parsed file
71  static async create (path, opts = {}) {
72    const p = new PackageJson()
73    await p.create(path)
74    if (opts.data) {
75      return p.update(opts.data)
76    }
77    return p
78  }
79
80  // Loads a package.json at given path and JSON parses
81  static async load (path, opts = {}) {
82    const p = new PackageJson()
83    // Avoid try/catch if we aren't going to create
84    if (!opts.create) {
85      return p.load(path)
86    }
87
88    try {
89      return await p.load(path)
90    } catch (err) {
91      if (!err.message.startsWith('Could not read package.json')) {
92        throw err
93      }
94      return await p.create(path)
95    }
96  }
97
98  // npm pkg fix
99  static async fix (path, opts) {
100    const p = new PackageJson()
101    await p.load(path, true)
102    return p.fix(opts)
103  }
104
105  // read-package-json compatible behavior
106  static async prepare (path, opts) {
107    const p = new PackageJson()
108    await p.load(path, true)
109    return p.prepare(opts)
110  }
111
112  // read-package-json-fast compatible behavior
113  static async normalize (path, opts) {
114    const p = new PackageJson()
115    await p.load(path)
116    return p.normalize(opts)
117  }
118
119  #path
120  #manifest
121  #readFileContent = ''
122  #canSave = true
123
124  // Load content from given path
125  async load (path, parseIndex) {
126    this.#path = path
127    let parseErr
128    try {
129      this.#readFileContent = await readFile(this.filename, 'utf8')
130    } catch (err) {
131      err.message = `Could not read package.json: ${err}`
132      if (!parseIndex) {
133        throw err
134      }
135      parseErr = err
136    }
137
138    if (parseErr) {
139      const indexFile = resolve(this.path, 'index.js')
140      let indexFileContent
141      try {
142        indexFileContent = await readFile(indexFile, 'utf8')
143      } catch (err) {
144        throw parseErr
145      }
146      try {
147        this.fromComment(indexFileContent)
148      } catch (err) {
149        throw parseErr
150      }
151      // This wasn't a package.json so prevent saving
152      this.#canSave = false
153      return this
154    }
155
156    return this.fromJSON(this.#readFileContent)
157  }
158
159  // Load data from a JSON string/buffer
160  fromJSON (data) {
161    try {
162      this.#manifest = parseJSON(data)
163    } catch (err) {
164      err.message = `Invalid package.json: ${err}`
165      throw err
166    }
167    return this
168  }
169
170  // Load data from a comment
171  // /**package { "name": "foo", "version": "1.2.3", ... } **/
172  fromComment (data) {
173    data = data.split(/^\/\*\*package(?:\s|$)/m)
174
175    if (data.length < 2) {
176      throw new Error('File has no package in comments')
177    }
178    data = data[1]
179    data = data.split(/\*\*\/$/m)
180
181    if (data.length < 2) {
182      throw new Error('File has no package in comments')
183    }
184    data = data[0]
185    data = data.replace(/^\s*\*/mg, '')
186
187    this.#manifest = parseJSON(data)
188    return this
189  }
190
191  get content () {
192    return this.#manifest
193  }
194
195  get path () {
196    return this.#path
197  }
198
199  get filename () {
200    if (this.path) {
201      return resolve(this.path, 'package.json')
202    }
203    return undefined
204  }
205
206  create (path) {
207    this.#path = path
208    this.#manifest = {}
209    return this
210  }
211
212  // This should be the ONLY way to set content in the manifest
213  update (content) {
214    if (!this.content) {
215      throw new Error('Can not update without content.  Please `load` or `create`')
216    }
217
218    for (const step of knownSteps) {
219      this.#manifest = step({ content, originalContent: this.content })
220    }
221
222    // unknown properties will just be overwitten
223    for (const [key, value] of Object.entries(content)) {
224      if (!knownKeys.has(key)) {
225        this.content[key] = value
226      }
227    }
228
229    return this
230  }
231
232  async save () {
233    if (!this.#canSave) {
234      throw new Error('No package.json to save to')
235    }
236    const {
237      [Symbol.for('indent')]: indent,
238      [Symbol.for('newline')]: newline,
239    } = this.content
240
241    const format = indent === undefined ? '  ' : indent
242    const eol = newline === undefined ? '\n' : newline
243    const fileContent = `${
244      JSON.stringify(this.content, null, format)
245    }\n`
246      .replace(/\n/g, eol)
247
248    if (fileContent.trim() !== this.#readFileContent.trim()) {
249      return await writeFile(this.filename, fileContent)
250    }
251  }
252
253  async normalize (opts = {}) {
254    if (!opts.steps) {
255      opts.steps = this.constructor.normalizeSteps
256    }
257    await normalize(this, opts)
258    return this
259  }
260
261  async prepare (opts = {}) {
262    if (!opts.steps) {
263      opts.steps = this.constructor.prepareSteps
264    }
265    await normalize(this, opts)
266    return this
267  }
268
269  async fix (opts = {}) {
270    // This one is not overridable
271    opts.steps = this.constructor.fixSteps
272    await normalize(this, opts)
273    return this
274  }
275}
276
277module.exports = PackageJson
278