1import JsonFile from '@expo/json-file';
2import npmPackageArg from 'npm-package-arg';
3import path from 'path';
4
5import { findYarnOrNpmWorkspaceRoot, NPM_LOCK_FILE } from '../utils/nodeWorkspaces';
6import { createPendingSpawnAsync } from '../utils/spawn';
7import { BasePackageManager } from './BasePackageManager';
8
9export class NpmPackageManager extends BasePackageManager {
10  readonly name = 'npm';
11  readonly bin = 'npm';
12  readonly lockFile = NPM_LOCK_FILE;
13
14  workspaceRoot() {
15    const root = findYarnOrNpmWorkspaceRoot(this.ensureCwdDefined('workspaceRoot'));
16    if (root) {
17      return new NpmPackageManager({
18        ...this.options,
19        silent: this.silent,
20        log: this.log,
21        cwd: root,
22      });
23    }
24
25    return null;
26  }
27
28  addAsync(namesOrFlags: string[] = []) {
29    if (!namesOrFlags.length) {
30      return this.installAsync();
31    }
32
33    const { flags, versioned, unversioned } = this.parsePackageSpecs(namesOrFlags);
34
35    return createPendingSpawnAsync(
36      () => this.updatePackageFileAsync(versioned, 'dependencies'),
37      () =>
38        !unversioned.length
39          ? this.runAsync(['install', ...flags])
40          : this.runAsync(['install', '--save', ...flags, ...unversioned.map((spec) => spec.raw)])
41    );
42  }
43
44  addDevAsync(namesOrFlags: string[] = []) {
45    if (!namesOrFlags.length) {
46      return this.installAsync();
47    }
48
49    const { flags, versioned, unversioned } = this.parsePackageSpecs(namesOrFlags);
50
51    return createPendingSpawnAsync(
52      () => this.updatePackageFileAsync(versioned, 'devDependencies'),
53      () =>
54        !unversioned.length
55          ? this.runAsync(['install', ...flags])
56          : this.runAsync([
57              'install',
58              '--save-dev',
59              ...flags,
60              ...unversioned.map((spec) => spec.raw),
61            ])
62    );
63  }
64
65  addGlobalAsync(namesOrFlags: string[] = []) {
66    if (!namesOrFlags.length) {
67      return this.installAsync();
68    }
69
70    return this.runAsync(['install', '--global', ...namesOrFlags]);
71  }
72
73  removeAsync(namesOrFlags: string[]) {
74    return this.runAsync(['uninstall', ...namesOrFlags]);
75  }
76
77  removeDevAsync(namesOrFlags: string[]) {
78    return this.runAsync(['uninstall', '--save-dev', ...namesOrFlags]);
79  }
80
81  removeGlobalAsync(namesOrFlags: string[]) {
82    return this.runAsync(['uninstall', '--global', ...namesOrFlags]);
83  }
84
85  /**
86   * Parse all package specifications from the names or flag list.
87   * The result from this method can be used for `.updatePackageFileAsync`.
88   */
89  private parsePackageSpecs(namesOrFlags: string[]) {
90    const result: {
91      flags: string[];
92      versioned: npmPackageArg.Result[];
93      unversioned: npmPackageArg.Result[];
94    } = { flags: [], versioned: [], unversioned: [] };
95
96    namesOrFlags
97      .map((name) => {
98        if (name.trim().startsWith('-')) {
99          result.flags.push(name);
100          return null;
101        }
102
103        return npmPackageArg(name);
104      })
105      .forEach((spec) => {
106        // When using a dist-tag version of a library, we need to consider it as "unversioned".
107        // Doing so will install that version with `npm install --save(-dev)`, and resolve the dist-tag properly.
108        if (spec && spec.rawSpec && spec.type !== 'tag') {
109          result.versioned.push(spec);
110        } else if (spec) {
111          result.unversioned.push(spec);
112        }
113      });
114
115    return result;
116  }
117
118  /**
119   * Older npm versions have issues with mismatched nested dependencies when adding exact versions.
120   * This propagates as issues like mismatched `@expo/config-pugins` versions.
121   * As a workaround, we update the `package.json` directly and run `npm install`.
122   */
123  private async updatePackageFileAsync(
124    packageSpecs: npmPackageArg.Result[],
125    packageType: 'dependencies' | 'devDependencies'
126  ) {
127    if (!packageSpecs.length) {
128      return;
129    }
130
131    const pkgPath = path.join(this.options.cwd?.toString() || '.', 'package.json');
132    const pkg = await JsonFile.readAsync<Record<typeof packageType, { [pkgName: string]: string }>>(
133      pkgPath
134    );
135
136    packageSpecs.forEach((spec) => {
137      pkg[packageType] = pkg[packageType] || {};
138      pkg[packageType][spec.name!] = spec.rawSpec;
139    });
140
141    await JsonFile.writeAsync(pkgPath, pkg, { json5: false });
142  }
143}
144