codekingpro/portable-devtools
114k
1const { log, output } = require('proc-log')
2const semver = require('semver')
3const pack = require('libnpmpack')
4const libpub = require('libnpmpublish').publish
5const runScript = require('@npmcli/run-script')
6const pacote = require('pacote')
7const npa = require('npm-package-arg')
8const npmFetch = require('npm-registry-fetch')
9const { redactLog: replaceInfo } = require('@npmcli/redact')
10const { otplease } = require('../utils/auth.js')
11const { getContents, logTar } = require('../utils/tar.js')
12// for historical reasons, publishConfig in package.json can contain ANY config keys that npm supports in .npmrc files and elsewhere.
13// We *may* want to revisit this at some point, and have a minimal set that's a SemVer-major change that ought to get a RFC written on it.
14const { flatten } = require('@npmcli/config/lib/definitions')
15const pkgJson = require('@npmcli/package-json')
16const BaseCommand = require('../base-cmd.js')
17const { oidc } = require('../../lib/utils/oidc.js')
18
19class Publish extends BaseCommand {
20 static description = 'Publish a package'
21 static name = 'publish'
22 static params = [
23 'tag',
24 'access',
25 'dry-run',
26 'otp',
27 'workspace',
28 'workspaces',
29 'include-workspace-root',
30 'provenance',
31 ]
32
33 static usage = ['<package-spec>']
34 static workspaces = true
35 static ignoreImplicitWorkspace = false
36
37 async exec (args) {
38 if (args.length === 0) {
39 args = ['.']
40 }
41 if (args.length !== 1) {
42 throw this.usageError()
43 }
44
45 await this.#publish(args)
46 }
47
48 async execWorkspaces (args) {
49 const useWorkspaces = args.length === 0 || args.includes('.')
50 if (!useWorkspaces) {
51 log.warn('Ignoring workspaces for specified package(s)')
52 return this.exec(args)
53 }
54 await this.setWorkspaces()
55
56 for (const [name, workspace] of this.workspaces.entries()) {
57 try {
58 await this.#publish([workspace], { workspace: name })
59 } catch (err) {
60 if (err.code !== 'EPRIVATE') {
61 throw err
62 }
63 log.warn('publish', `Skipping workspace ${this.npm.chalk.cyan(name)}, marked as ${this.npm.chalk.bold('private')}`)
64 }
65 }
66 }
67
68 async #publish (args, { workspace } = {}) {
69 log.verbose('publish', replaceInfo(args))
70
71 const unicode = this.npm.config.get('unicode')
72 const dryRun = this.npm.config.get('dry-run')
73 const json = this.npm.config.get('json')
74 const defaultTag = this.npm.config.get('tag')
75 const ignoreScripts = this.npm.config.get('ignore-scripts')
76 const { silent } = this.npm
77
78 if (semver.validRange(defaultTag)) {
79 throw new Error('Tag name must not be a valid SemVer range: ' + defaultTag.trim())
80 }
81
82 const opts = { ...this.npm.flatOptions, progress: false }
83
84 // you can publish name@version, ./foo.tgz, etc even though the default is the 'file:.' cwd.
85 const spec = npa(args[0])
86 let manifest = await this.#getManifest(spec, opts)
87
88 // only run scripts for directory type publishes
89 if (spec.type === 'directory' && !ignoreScripts) {
90 await runScript({
91 event: 'prepublishOnly',
92 path: spec.fetchSpec,
93 stdio: 'inherit',
94 pkg: manifest,
95 })
96 }
97
98 // we pass dryRun: true to libnpmpack so it doesn't write the file to disk
99 const tarballData = await pack(spec, {
100 ...opts,
101 foregroundScripts: this.npm.config.isDefault('foreground-scripts')
102 ? true
103 : this.npm.config.get('foreground-scripts'),
104 dryRun: true,
105 prefix: this.npm.localPrefix,
106 workspaces: this.workspacePaths,
107 })
108 const pkgContents = await getContents(manifest, tarballData)
109 const logPkg = () => logTar(pkgContents, { unicode, json, key: workspace })
110
111 // The purpose of re-reading the manifest is in case it changed, so that we send the latest and greatest thing to the registry note that publishConfig might have changed as well!
112 manifest = await this.#getManifest(spec, opts, true)
113 const force = this.npm.config.get('force')
114 const isDefaultTag = this.npm.config.isDefault('tag') && !manifest.publishConfig?.tag
115
116 if (!force) {
117 const isPreRelease = Boolean(semver.parse(manifest.version).prerelease.length)
118 if (isPreRelease && isDefaultTag) {
119 throw new Error('You must specify a tag using --tag when publishing a prerelease version.')
120 }
121 }
122
123 // If we are not in JSON mode then we show the user the contents of the tarball before it is published so they can see it while their otp is pending
124 if (!json) {
125 logPkg()
126 }
127
128 const resolved = npa.resolve(manifest.name, manifest.version)
129
130 // make sure tag is valid, this will throw if invalid
131 npa(`${manifest.name}@${defaultTag}`)
132
133 const registry = npmFetch.pickRegistry(resolved, opts)
134
135 await oidc({ packageName: manifest.name, registry, opts, config: this.npm.config })
136
137 const creds = this.npm.config.getCredentialsByURI(registry)
138 const noCreds = !(creds.token || creds.username || creds.certfile && creds.keyfile)
139 const outputRegistry = replaceInfo(registry)
140
141 // if a workspace package is marked private then we skip it
142 if (workspace && manifest.private) {
143 throw Object.assign(
144 new Error(`This package has been marked as private
145 Remove the 'private' field from the package.json to publish it.`),
146 { code: 'EPRIVATE' }
147 )
148 }
149
150 if (noCreds) {
151 const msg = `This command requires you to be logged in to ${outputRegistry}`
152 if (dryRun) {
153 log.warn('', `${msg} (dry-run)`)
154 } else {
155 throw Object.assign(new Error(msg), { code: 'ENEEDAUTH' })
156 }
157 }
158
159 if (!force) {
160 const { highestVersion, versions } = await this.#registryVersions(resolved, registry)
161 /* eslint-disable-next-line max-len */
162 const highestVersionIsGreater = !!highestVersion && semver.gte(highestVersion, manifest.version)
163
164 if (versions.includes(manifest.version)) {
165 throw new Error(`You cannot publish over the previously published versions: ${manifest.version}.`)
166 }
167
168 if (highestVersionIsGreater && isDefaultTag) {
169 throw new Error(`Cannot implicitly apply the "latest" tag because previously published version ${highestVersion} is higher than the new version ${manifest.version}. You must specify a tag using --tag.`)
170 }
171 }
172
173 const access = opts.access === null ? 'default' : opts.access
174 let msg = `Publishing to ${outputRegistry} with tag ${defaultTag} and ${access} access`
175 if (dryRun) {
176 msg = `${msg} (dry-run)`
177 }
178
179 log.notice('', msg)
180
181 if (!dryRun) {
182 await otplease(this.npm, opts, o => libpub(manifest, tarballData, o))
183 }
184
185 // In json mode we don't log until the publish has completed as this will add it to the output only if completes successfully
186 if (json) {
187 logPkg()
188 }
189
190 if (spec.type === 'directory' && !ignoreScripts) {
191 await runScript({
192 event: 'publish',
193 path: spec.fetchSpec,
194 stdio: 'inherit',
195 pkg: manifest,
196 })
197
198 await runScript({
199 event: 'postpublish',
200 path: spec.fetchSpec,
201 stdio: 'inherit',
202 pkg: manifest,
203 })
204 }
205
206 if (!json && !silent) {
207 output.standard(`+ ${pkgContents.id}`)
208 }
209 }
210
211 async #registryVersions (spec, registry) {
212 try {
213 const packument = await pacote.packument(spec, {
214 ...this.npm.flatOptions,
215 preferOnline: true,
216 registry,
217 _isRoot: true,
218 })
219 if (typeof packument?.versions === 'undefined') {
220 return { versions: [], highestVersion: null }
221 }
222 const ordered = Object.keys(packument?.versions)
223 .flatMap(v => {
224 const s = new semver.SemVer(v)
225 if ((s.prerelease.length > 0) || packument.versions[v].deprecated) {
226 return []
227 }
228 return s
229 })
230 .sort((a, b) => b.compare(a))
231 const highestVersion = ordered.length >= 1 ? ordered[0].version : null
232 const versions = ordered.map(v => v.version)
233 return { versions, highestVersion }
234 } catch (e) {
235 return { versions: [], highestVersion: null }
236 }
237 }
238
239 // if it's a directory, read it from the file system
240 // otherwise, get the full metadata from whatever it is
241 // XXX can't pacote read the manifest from a directory?
242 async #getManifest (spec, opts, logWarnings = false) {
243 let manifest
244 if (spec.type === 'directory') {
245 const changes = []
246 const pkg = await pkgJson.fix(spec.fetchSpec, { changes })
247 if (changes.length && logWarnings) {
248 log.warn('publish', 'npm auto-corrected some errors in your package.json when publishing. Please run "npm pkg fix" to address these errors.')
249 log.warn('publish', `errors corrected:\n${changes.join('\n')}`)
250 }
251 // Prepare is the special function for publishing, different than normalize
252 const { content } = await pkg.prepare()
253 manifest = content
254 } else {
255 manifest = await pacote.manifest(spec, {
256 ...opts,
257 fullmetadata: true,
258 fullReadJson: true,
259 })
260 }
261 if (manifest.publishConfig) {
262 const cliFlags = this.npm.config.data.get('cli').raw
263 // Filter out properties set in CLI flags to prioritize them over corresponding `publishConfig` settings
264 const filteredPublishConfig = Object.fromEntries(
265 Object.entries(manifest.publishConfig).filter(([key]) => !(key in cliFlags)))
266 if (logWarnings) {
267 for (const key in filteredPublishConfig) {
268 this.npm.config.checkUnknown('publishConfig', key)
269 }
270 }
271 flatten(filteredPublishConfig, opts)
272 }
273 return manifest
274 }
275}
276
277module.exports = Publish
278 