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