1const libaccess = require('libnpmaccess')
2const libunpub = require('libnpmpublish').unpublish
3const npa = require('npm-package-arg')
4const pacote = require('pacote')
5const pkgJson = require('@npmcli/package-json')
6
7const { flatten } = require('@npmcli/config/lib/definitions')
8const getIdentity = require('../utils/get-identity.js')
9const log = require('../utils/log-shim')
10const otplease = require('../utils/otplease.js')
11
12const LAST_REMAINING_VERSION_ERROR = 'Refusing to delete the last version of the package. ' +
13'It will block from republishing a new version for 24 hours.\n' +
14'Run with --force to do this.'
15
16const BaseCommand = require('../base-command.js')
17class Unpublish extends BaseCommand {
18  static description = 'Remove a package from the registry'
19  static name = 'unpublish'
20  static params = ['dry-run', 'force', 'workspace', 'workspaces']
21  static usage = ['[<package-spec>]']
22  static workspaces = true
23  static ignoreImplicitWorkspace = false
24
25  static async getKeysOfVersions (name, opts) {
26    const packument = await pacote.packument(name, {
27      ...opts,
28      spec: name,
29      query: { write: true },
30    })
31    return Object.keys(packument.versions)
32  }
33
34  static async completion (args, npm) {
35    const { partialWord, conf } = args
36
37    if (conf.argv.remain.length >= 3) {
38      return []
39    }
40
41    const opts = { ...npm.flatOptions }
42    const username = await getIdentity(npm, { ...opts }).catch(() => null)
43    if (!username) {
44      return []
45    }
46
47    const access = await libaccess.getPackages(username, opts)
48    // do a bit of filtering at this point, so that we don't need
49    // to fetch versions for more than one thing, but also don't
50    // accidentally unpublish a whole project
51    let pkgs = Object.keys(access)
52    if (!partialWord || !pkgs.length) {
53      return pkgs
54    }
55
56    const pp = npa(partialWord).name
57    pkgs = pkgs.filter(p => !p.indexOf(pp))
58    if (pkgs.length > 1) {
59      return pkgs
60    }
61
62    const versions = await Unpublish.getKeysOfVersions(pkgs[0], opts)
63    if (!versions.length) {
64      return pkgs
65    } else {
66      return versions.map(v => `${pkgs[0]}@${v}`)
67    }
68  }
69
70  async exec (args, { localPrefix } = {}) {
71    if (args.length > 1) {
72      throw this.usageError()
73    }
74
75    // workspace mode
76    if (!localPrefix) {
77      localPrefix = this.npm.localPrefix
78    }
79
80    const force = this.npm.config.get('force')
81    const { silent } = this.npm
82    const dryRun = this.npm.config.get('dry-run')
83
84    let spec
85    if (args.length) {
86      spec = npa(args[0])
87      if (spec.type !== 'version' && spec.rawSpec !== '*') {
88        throw this.usageError(
89          'Can only unpublish a single version, or the entire project.\n' +
90          'Tags and ranges are not supported.'
91        )
92      }
93    }
94
95    log.silly('unpublish', 'args[0]', args[0])
96    log.silly('unpublish', 'spec', spec)
97
98    if (spec?.rawSpec === '*' && !force) {
99      throw this.usageError(
100        'Refusing to delete entire project.\n' +
101        'Run with --force to do this.'
102      )
103    }
104
105    const opts = { ...this.npm.flatOptions }
106
107    let manifest
108    try {
109      const { content } = await pkgJson.prepare(localPrefix)
110      manifest = content
111    } catch (err) {
112      if (err.code === 'ENOENT' || err.code === 'ENOTDIR') {
113        if (!spec) {
114          // We needed a local package.json to figure out what package to
115          // unpublish
116          throw this.usageError()
117        }
118      } else {
119        // folks should know if ANY local package.json had a parsing error.
120        // They may be relying on `publishConfig` to be loading and we don't
121        // want to ignore errors in that case.
122        throw err
123      }
124    }
125
126    let pkgVersion // for cli output
127    if (spec) {
128      pkgVersion = spec.type === 'version' ? `@${spec.rawSpec}` : ''
129    } else {
130      spec = npa.resolve(manifest.name, manifest.version)
131      log.verbose('unpublish', manifest)
132      pkgVersion = manifest.version ? `@${manifest.version}` : ''
133      if (!manifest.version && !force) {
134        throw this.usageError(
135          'Refusing to delete entire project.\n' +
136          'Run with --force to do this.'
137        )
138      }
139    }
140
141    // If localPrefix has a package.json with a name that matches the package
142    // being unpublished, load up the publishConfig
143    if (manifest?.name === spec.name && manifest.publishConfig) {
144      flatten(manifest.publishConfig, opts)
145    }
146
147    const versions = await Unpublish.getKeysOfVersions(spec.name, opts)
148    if (versions.length === 1 && spec.rawSpec === versions[0] && !force) {
149      throw this.usageError(LAST_REMAINING_VERSION_ERROR)
150    }
151    if (versions.length === 1) {
152      pkgVersion = ''
153    }
154
155    if (!dryRun) {
156      await otplease(this.npm, opts, o => libunpub(spec, o))
157    }
158    if (!silent) {
159      this.npm.output(`- ${spec.name}${pkgVersion}`)
160    }
161  }
162
163  async execWorkspaces (args) {
164    await this.setWorkspaces()
165
166    for (const path of this.workspacePaths) {
167      await this.exec(args, { localPrefix: path })
168    }
169  }
170}
171module.exports = Unpublish
172