appium 3.2.2 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/build/lib/appium.d.ts +147 -205
  2. package/build/lib/appium.d.ts.map +1 -1
  3. package/build/lib/appium.js +169 -282
  4. package/build/lib/appium.js.map +1 -1
  5. package/build/lib/bidi-commands.d.ts.map +1 -1
  6. package/build/lib/bidi-commands.js +11 -11
  7. package/build/lib/bidi-commands.js.map +1 -1
  8. package/build/lib/bootstrap/appium-initializer.d.ts +21 -0
  9. package/build/lib/bootstrap/appium-initializer.d.ts.map +1 -0
  10. package/build/lib/bootstrap/appium-initializer.js +146 -0
  11. package/build/lib/bootstrap/appium-initializer.js.map +1 -0
  12. package/build/lib/bootstrap/appium-main-runner.d.ts +22 -0
  13. package/build/lib/bootstrap/appium-main-runner.d.ts.map +1 -0
  14. package/build/lib/bootstrap/appium-main-runner.js +109 -0
  15. package/build/lib/bootstrap/appium-main-runner.js.map +1 -0
  16. package/build/lib/bootstrap/config-file.d.ts +37 -0
  17. package/build/lib/bootstrap/config-file.d.ts.map +1 -0
  18. package/build/lib/{config-file.js → bootstrap/config-file.js} +62 -138
  19. package/build/lib/bootstrap/config-file.js.map +1 -0
  20. package/build/lib/bootstrap/grid-v3-register.d.ts +20 -0
  21. package/build/lib/bootstrap/grid-v3-register.d.ts.map +1 -0
  22. package/build/lib/bootstrap/grid-v3-register.js +185 -0
  23. package/build/lib/bootstrap/grid-v3-register.js.map +1 -0
  24. package/build/lib/bootstrap/init-types.d.ts +16 -0
  25. package/build/lib/bootstrap/init-types.d.ts.map +1 -0
  26. package/build/lib/bootstrap/init-types.js +3 -0
  27. package/build/lib/bootstrap/init-types.js.map +1 -0
  28. package/build/lib/bootstrap/main-helpers.d.ts +55 -0
  29. package/build/lib/bootstrap/main-helpers.d.ts.map +1 -0
  30. package/build/lib/bootstrap/main-helpers.js +187 -0
  31. package/build/lib/bootstrap/main-helpers.js.map +1 -0
  32. package/build/lib/bootstrap/node-helpers.d.ts +32 -0
  33. package/build/lib/bootstrap/node-helpers.d.ts.map +1 -0
  34. package/build/lib/bootstrap/node-helpers.js +201 -0
  35. package/build/lib/bootstrap/node-helpers.js.map +1 -0
  36. package/build/lib/bootstrap/startup-config.d.ts +22 -0
  37. package/build/lib/bootstrap/startup-config.d.ts.map +1 -0
  38. package/build/lib/bootstrap/startup-config.js +111 -0
  39. package/build/lib/bootstrap/startup-config.js.map +1 -0
  40. package/build/lib/cli/args.d.ts +16 -12
  41. package/build/lib/cli/args.d.ts.map +1 -1
  42. package/build/lib/cli/args.js +20 -40
  43. package/build/lib/cli/args.js.map +1 -1
  44. package/build/lib/cli/driver-command.d.ts +51 -93
  45. package/build/lib/cli/driver-command.d.ts.map +1 -1
  46. package/build/lib/cli/driver-command.js +11 -66
  47. package/build/lib/cli/driver-command.js.map +1 -1
  48. package/build/lib/cli/extension-command.d.ts +173 -377
  49. package/build/lib/cli/extension-command.d.ts.map +1 -1
  50. package/build/lib/cli/extension-command.js +387 -656
  51. package/build/lib/cli/extension-command.js.map +1 -1
  52. package/build/lib/cli/extension.d.ts +10 -15
  53. package/build/lib/cli/extension.d.ts.map +1 -1
  54. package/build/lib/cli/extension.js +15 -33
  55. package/build/lib/cli/extension.js.map +1 -1
  56. package/build/lib/cli/parser.d.ts +37 -66
  57. package/build/lib/cli/parser.d.ts.map +1 -1
  58. package/build/lib/cli/parser.js +69 -104
  59. package/build/lib/cli/parser.js.map +1 -1
  60. package/build/lib/cli/plugin-command.d.ts +50 -90
  61. package/build/lib/cli/plugin-command.d.ts.map +1 -1
  62. package/build/lib/cli/plugin-command.js +11 -63
  63. package/build/lib/cli/plugin-command.js.map +1 -1
  64. package/build/lib/cli/setup-command.d.ts +21 -26
  65. package/build/lib/cli/setup-command.d.ts.map +1 -1
  66. package/build/lib/cli/setup-command.js +19 -61
  67. package/build/lib/cli/setup-command.js.map +1 -1
  68. package/build/lib/cli/utils.d.ts +33 -35
  69. package/build/lib/cli/utils.d.ts.map +1 -1
  70. package/build/lib/cli/utils.js +48 -50
  71. package/build/lib/cli/utils.js.map +1 -1
  72. package/build/lib/constants.d.ts +23 -23
  73. package/build/lib/constants.d.ts.map +1 -1
  74. package/build/lib/constants.js +10 -15
  75. package/build/lib/constants.js.map +1 -1
  76. package/build/lib/doctor/doctor.d.ts +40 -57
  77. package/build/lib/doctor/doctor.d.ts.map +1 -1
  78. package/build/lib/doctor/doctor.js +31 -62
  79. package/build/lib/doctor/doctor.js.map +1 -1
  80. package/build/lib/extension/driver-config.d.ts +18 -77
  81. package/build/lib/extension/driver-config.d.ts.map +1 -1
  82. package/build/lib/extension/driver-config.js +37 -125
  83. package/build/lib/extension/driver-config.js.map +1 -1
  84. package/build/lib/extension/extension-config.d.ts +103 -210
  85. package/build/lib/extension/extension-config.d.ts.map +1 -1
  86. package/build/lib/extension/extension-config.js +180 -342
  87. package/build/lib/extension/extension-config.js.map +1 -1
  88. package/build/lib/extension/index.d.ts +12 -29
  89. package/build/lib/extension/index.d.ts.map +1 -1
  90. package/build/lib/extension/index.js +33 -75
  91. package/build/lib/extension/index.js.map +1 -1
  92. package/build/lib/extension/manifest-migrations.d.ts +3 -20
  93. package/build/lib/extension/manifest-migrations.d.ts.map +1 -1
  94. package/build/lib/extension/manifest-migrations.js +20 -101
  95. package/build/lib/extension/manifest-migrations.js.map +1 -1
  96. package/build/lib/extension/manifest.d.ts +61 -107
  97. package/build/lib/extension/manifest.d.ts.map +1 -1
  98. package/build/lib/extension/manifest.js +181 -356
  99. package/build/lib/extension/manifest.js.map +1 -1
  100. package/build/lib/extension/package-changed.d.ts +1 -3
  101. package/build/lib/extension/package-changed.d.ts.map +1 -1
  102. package/build/lib/extension/package-changed.js +8 -15
  103. package/build/lib/extension/package-changed.js.map +1 -1
  104. package/build/lib/extension/plugin-config.d.ts +10 -52
  105. package/build/lib/extension/plugin-config.d.ts.map +1 -1
  106. package/build/lib/extension/plugin-config.js +11 -63
  107. package/build/lib/extension/plugin-config.js.map +1 -1
  108. package/build/lib/helpers/build.d.ts +22 -0
  109. package/build/lib/helpers/build.d.ts.map +1 -0
  110. package/build/lib/helpers/build.js +109 -0
  111. package/build/lib/helpers/build.js.map +1 -0
  112. package/build/lib/helpers/capability.d.ts +38 -0
  113. package/build/lib/helpers/capability.d.ts.map +1 -0
  114. package/build/lib/helpers/capability.js +128 -0
  115. package/build/lib/helpers/capability.js.map +1 -0
  116. package/build/lib/helpers/network.d.ts +14 -0
  117. package/build/lib/helpers/network.d.ts.map +1 -0
  118. package/build/lib/helpers/network.js +35 -0
  119. package/build/lib/helpers/network.js.map +1 -0
  120. package/build/lib/insecure-features.js +6 -6
  121. package/build/lib/insecure-features.js.map +1 -1
  122. package/build/lib/inspector-commands.d.ts +6 -0
  123. package/build/lib/inspector-commands.d.ts.map +1 -1
  124. package/build/lib/inspector-commands.js +6 -0
  125. package/build/lib/inspector-commands.js.map +1 -1
  126. package/build/lib/logger.d.ts +2 -3
  127. package/build/lib/logger.d.ts.map +1 -1
  128. package/build/lib/logger.js +2 -3
  129. package/build/lib/logger.js.map +1 -1
  130. package/build/lib/logsink.d.ts +13 -22
  131. package/build/lib/logsink.d.ts.map +1 -1
  132. package/build/lib/logsink.js +48 -103
  133. package/build/lib/logsink.js.map +1 -1
  134. package/build/lib/main.d.ts +15 -58
  135. package/build/lib/main.d.ts.map +1 -1
  136. package/build/lib/main.js +25 -425
  137. package/build/lib/main.js.map +1 -1
  138. package/build/lib/schema/arg-spec.d.ts +32 -107
  139. package/build/lib/schema/arg-spec.d.ts.map +1 -1
  140. package/build/lib/schema/arg-spec.js +11 -107
  141. package/build/lib/schema/arg-spec.js.map +1 -1
  142. package/build/lib/schema/cli-args-guards.d.ts +34 -0
  143. package/build/lib/schema/cli-args-guards.d.ts.map +1 -0
  144. package/build/lib/schema/cli-args-guards.js +49 -0
  145. package/build/lib/schema/cli-args-guards.js.map +1 -0
  146. package/build/lib/schema/cli-args.d.ts +3 -15
  147. package/build/lib/schema/cli-args.d.ts.map +1 -1
  148. package/build/lib/schema/cli-args.js +17 -107
  149. package/build/lib/schema/cli-args.js.map +1 -1
  150. package/build/lib/schema/cli-transformers.d.ts +15 -12
  151. package/build/lib/schema/cli-transformers.d.ts.map +1 -1
  152. package/build/lib/schema/cli-transformers.js +15 -45
  153. package/build/lib/schema/cli-transformers.js.map +1 -1
  154. package/build/lib/schema/format-errors.d.ts +28 -0
  155. package/build/lib/schema/format-errors.d.ts.map +1 -0
  156. package/build/lib/schema/format-errors.js +29 -0
  157. package/build/lib/schema/format-errors.js.map +1 -0
  158. package/build/lib/schema/index.d.ts +4 -2
  159. package/build/lib/schema/index.d.ts.map +1 -1
  160. package/build/lib/schema/index.js +2 -0
  161. package/build/lib/schema/index.js.map +1 -1
  162. package/build/lib/schema/keywords.d.ts +12 -20
  163. package/build/lib/schema/keywords.d.ts.map +1 -1
  164. package/build/lib/schema/keywords.js +6 -51
  165. package/build/lib/schema/keywords.js.map +1 -1
  166. package/build/lib/schema/schema.d.ts +106 -231
  167. package/build/lib/schema/schema.d.ts.map +1 -1
  168. package/build/lib/schema/schema.js +88 -358
  169. package/build/lib/schema/schema.js.map +1 -1
  170. package/build/lib/utils.d.ts +7 -267
  171. package/build/lib/utils.d.ts.map +1 -1
  172. package/build/lib/utils.js +10 -409
  173. package/build/lib/utils.js.map +1 -1
  174. package/lib/{appium.js → appium.ts} +297 -341
  175. package/lib/bidi-commands.ts +10 -14
  176. package/lib/bootstrap/appium-initializer.ts +212 -0
  177. package/lib/bootstrap/appium-main-runner.ts +172 -0
  178. package/lib/bootstrap/config-file.ts +178 -0
  179. package/lib/bootstrap/grid-v3-register.ts +250 -0
  180. package/lib/bootstrap/init-types.ts +31 -0
  181. package/lib/bootstrap/main-helpers.ts +223 -0
  182. package/lib/bootstrap/node-helpers.ts +180 -0
  183. package/lib/bootstrap/startup-config.ts +143 -0
  184. package/lib/cli/{args.js → args.ts} +45 -56
  185. package/lib/cli/driver-command.ts +122 -0
  186. package/lib/cli/{extension-command.js → extension-command.ts} +827 -906
  187. package/lib/cli/extension.ts +65 -0
  188. package/lib/cli/{parser.js → parser.ts} +93 -116
  189. package/lib/cli/plugin-command.ts +117 -0
  190. package/lib/cli/{setup-command.js → setup-command.ts} +59 -74
  191. package/lib/cli/utils.ts +97 -0
  192. package/lib/{constants.js → constants.ts} +30 -41
  193. package/lib/doctor/{doctor.js → doctor.ts} +82 -92
  194. package/lib/extension/driver-config.ts +165 -0
  195. package/lib/extension/{extension-config.js → extension-config.ts} +291 -405
  196. package/lib/extension/index.ts +143 -0
  197. package/lib/extension/manifest-migrations.ts +57 -0
  198. package/lib/extension/manifest.ts +369 -0
  199. package/lib/extension/{package-changed.js → package-changed.ts} +9 -18
  200. package/lib/extension/plugin-config.ts +62 -0
  201. package/lib/helpers/build.ts +111 -0
  202. package/lib/helpers/capability.ts +171 -0
  203. package/lib/helpers/network.ts +30 -0
  204. package/lib/insecure-features.ts +1 -1
  205. package/lib/inspector-commands.ts +6 -1
  206. package/lib/{logger.js → logger.ts} +1 -2
  207. package/lib/{logsink.js → logsink.ts} +91 -137
  208. package/lib/main.ts +60 -0
  209. package/lib/schema/arg-spec.ts +131 -0
  210. package/lib/schema/cli-args-guards.ts +67 -0
  211. package/lib/schema/cli-args.ts +171 -0
  212. package/lib/schema/cli-transformers.ts +83 -0
  213. package/lib/schema/format-errors.ts +43 -0
  214. package/lib/schema/index.ts +4 -0
  215. package/lib/schema/keywords.ts +96 -0
  216. package/lib/schema/schema.ts +448 -0
  217. package/lib/utils.ts +73 -0
  218. package/package.json +17 -18
  219. package/scripts/autoinstall-extensions.js +3 -0
  220. package/build/lib/config-file.d.ts +0 -100
  221. package/build/lib/config-file.d.ts.map +0 -1
  222. package/build/lib/config-file.js.map +0 -1
  223. package/build/lib/config.d.ts +0 -70
  224. package/build/lib/config.d.ts.map +0 -1
  225. package/build/lib/config.js +0 -390
  226. package/build/lib/config.js.map +0 -1
  227. package/build/lib/grid-register.d.ts +0 -10
  228. package/build/lib/grid-register.d.ts.map +0 -1
  229. package/build/lib/grid-register.js +0 -134
  230. package/build/lib/grid-register.js.map +0 -1
  231. package/lib/cli/driver-command.js +0 -174
  232. package/lib/cli/extension.js +0 -74
  233. package/lib/cli/plugin-command.js +0 -164
  234. package/lib/cli/utils.js +0 -91
  235. package/lib/config-file.js +0 -228
  236. package/lib/config.js +0 -389
  237. package/lib/extension/driver-config.js +0 -245
  238. package/lib/extension/index.js +0 -169
  239. package/lib/extension/manifest-migrations.js +0 -136
  240. package/lib/extension/manifest.js +0 -550
  241. package/lib/extension/plugin-config.js +0 -112
  242. package/lib/grid-register.js +0 -146
  243. package/lib/main.js +0 -545
  244. package/lib/schema/arg-spec.js +0 -229
  245. package/lib/schema/cli-args.js +0 -254
  246. package/lib/schema/cli-transformers.js +0 -113
  247. package/lib/schema/index.js +0 -2
  248. package/lib/schema/keywords.js +0 -136
  249. package/lib/schema/schema.js +0 -725
  250. package/lib/utils.js +0 -512
@@ -1,6 +1,17 @@
1
1
  import B from 'bluebird';
2
2
  import _ from 'lodash';
3
3
  import path from 'node:path';
4
+ import type {AppiumLogger, ExtensionType, IDoctorCheck} from '@appium/types';
5
+ import type {
6
+ ExtInstallReceipt as AppiumExtInstallReceipt,
7
+ ExtManifest as AppiumExtManifest,
8
+ ExtMetadata as AppiumExtMetadata,
9
+ ExtPackageJson as AppiumExtPackageJson,
10
+ ExtRecord as AppiumExtRecord,
11
+ InstallType,
12
+ } from 'appium/types';
13
+ import type {PackageJson} from 'type-fest';
14
+ import type {ExtensionConfig as BaseExtensionConfig} from '../extension/extension-config';
4
15
  import {npm, util, env, console, fs, system} from '@appium/support';
5
16
  import {spinWith, RingBuffer} from './utils';
6
17
  import {
@@ -22,63 +33,164 @@ import * as semver from 'semver';
22
33
  const UPDATE_ALL = 'installed';
23
34
  const MAX_CONCURRENT_REPO_FETCHES = 5;
24
35
 
25
- class NotUpdatableError extends Error {}
26
- class NoUpdatesAvailableError extends Error {}
36
+ /**
37
+ * Options for the {@linkcode ExtensionCliCommand} constructor
38
+ */
39
+ export type ExtensionCommandOptions<ExtType extends ExtensionType = ExtensionType> = {
40
+ config: ExtensionConfig<ExtType>;
41
+ json: boolean;
42
+ };
43
+ export type ExtensionConfig<ExtType extends ExtensionType = ExtensionType> = BaseExtensionConfig<ExtType>;
44
+
45
+ export type ExtRecord<ExtType extends ExtensionType = ExtensionType> = AppiumExtRecord<ExtType>;
46
+
47
+ export type ExtMetadata<ExtType extends ExtensionType = ExtensionType> = AppiumExtMetadata<ExtType>;
48
+ export type ExtManifest<ExtType extends ExtensionType = ExtensionType> = AppiumExtManifest<ExtType>;
49
+ export type ExtPackageJson<ExtType extends ExtensionType = ExtensionType> = AppiumExtPackageJson<ExtType>;
50
+ export type ExtInstallReceipt<ExtType extends ExtensionType = ExtensionType> =
51
+ AppiumExtInstallReceipt<ExtType>;
52
+ /**
53
+ * Extra stuff about extensions; used indirectly by {@linkcode ExtensionCliCommand.list}.
54
+ */
55
+ export type ExtensionListMetadata = {
56
+ installed: boolean;
57
+ upToDate?: boolean;
58
+ updateVersion?: string | null;
59
+ unsafeUpdateVersion?: string | null;
60
+ updateError?: string;
61
+ devMode?: boolean;
62
+ repositoryUrl?: string;
63
+ };
64
+ /**
65
+ * Possible return value for {@linkcode ExtensionCliCommand.list}
66
+ */
67
+ export type ExtensionListData<ExtType extends ExtensionType = ExtensionType> = Partial<
68
+ ExtManifest<ExtType>
69
+ > &
70
+ Partial<ExtensionListMetadata>;
71
+
72
+ export type InstalledExtensionListData<ExtType extends ExtensionType = ExtensionType> = ExtManifest<ExtType> &
73
+ ExtensionListMetadata;
27
74
 
28
75
  /**
29
- * Omits `driverName`/`pluginName` props from the receipt to make a {@linkcode ExtManifest}
30
- * @template {ExtensionType} ExtType
31
- * @param {ExtInstallReceipt<ExtType>} receipt
32
- * @returns {ExtManifest<ExtType>}
76
+ * Return value of {@linkcode ExtensionCliCommand.list}.
33
77
  */
34
- function receiptToManifest(receipt) {
35
- return /** @type {ExtManifest<ExtType>} */ (_.omit(receipt, 'driverName', 'pluginName'));
36
- }
78
+ export type ExtensionList<ExtType extends ExtensionType = ExtensionType> = Record<
79
+ string,
80
+ ExtensionListData<ExtType>
81
+ >;
37
82
 
38
83
  /**
39
- * Fetches the remote extension version requirements
40
- *
41
- * @param {string} pkgName Extension name
42
- * @param {string} [pkgVer] Extension version (if not provided then the latest is assumed)
43
- * @returns {Promise<[string, string|null]>}
84
+ * Return value of {@linkcode ExtensionCliCommand._run}
44
85
  */
45
- async function getRemoteExtensionVersionReq(pkgName, pkgVer) {
46
- const allDeps = await npm.getPackageInfo(
47
- `${pkgName}${pkgVer ? `@${pkgVer}` : ``}`,
48
- ['peerDependencies', 'dependencies']
49
- );
50
- const requiredVersionPair = _.flatMap(_.values(allDeps).map(_.toPairs))
51
- .find(([name]) => name === 'appium');
52
- return [npmPackage.version, requiredVersionPair ? requiredVersionPair[1] : null];
53
- }
86
+ export type RunOutput = {output?: string[]};
87
+
88
+ /**
89
+ * Return type of {@linkcode ExtensionCliCommand.getPostInstallText}.
90
+ */
91
+ export type PostInstallText = string;
92
+
93
+ /**
94
+ * Return value of {@linkcode ExtensionCliCommand._update}.
95
+ */
96
+ export type ExtensionUpdateResult = {errors: Record<string, Error>; updates: Record<string, UpdateReport>};
97
+
98
+ /**
99
+ * Used by {@linkcode ExtensionCliCommand.getPostInstallText}
100
+ */
101
+ export type ExtensionArgs<ExtType extends ExtensionType = ExtensionType> = {
102
+ extName: string;
103
+ extData: ExtInstallReceipt<ExtType>;
104
+ };
105
+
106
+ /**
107
+ * Options for {@linkcode ExtensionCliCommand._run}.
108
+ */
109
+ type RunOptions = {
110
+ installSpec: string;
111
+ scriptName?: string;
112
+ extraArgs?: string[];
113
+ bufferOutput?: boolean;
114
+ };
115
+
116
+ /**
117
+ * Options for {@linkcode ExtensionCliCommand.doctor}.
118
+ */
119
+ type DoctorOptions = {installSpec: string};
120
+
121
+ /**
122
+ * Options for {@linkcode ExtensionCliCommand._update}.
123
+ */
124
+ type ExtensionUpdateOpts = {installSpec: string; unsafe: boolean};
125
+
126
+ /**
127
+ * Part of result of {@linkcode ExtensionCliCommand._update}.
128
+ */
129
+ type UpdateReport = {from: string; to: string | null};
130
+
131
+ /**
132
+ * Options for {@linkcode ExtensionCliCommand._uninstall}.
133
+ */
134
+ type UninstallOpts = {installSpec: string};
135
+
136
+ /**
137
+ * Options for {@linkcode ExtensionCliCommand.installViaNpm}
138
+ */
139
+ type InstallViaNpmArgs = {
140
+ installSpec: string;
141
+ pkgName: string;
142
+ installType: InstallType;
143
+ pkgVer?: string;
144
+ };
145
+
146
+ /**
147
+ * Object returned by {@linkcode ExtensionCliCommand.checkForExtensionUpdate}
148
+ */
149
+ type PossibleUpdates = {current: string; safeUpdate: string | null; unsafeUpdate: string | null};
54
150
 
55
151
  /**
56
- * @template {ExtensionType} ExtType
152
+ * Options for {@linkcode ExtensionCliCommand._install}
57
153
  */
58
- class ExtensionCliCommand {
154
+ type InstallOpts = {installSpec: string; installType: InstallType; packageName?: string};
155
+
156
+ type ListOptions = {showInstalled: boolean; showUpdates: boolean; verbose?: boolean};
157
+
158
+ type GetInstallationReceiptOpts<ExtType extends ExtensionType = ExtensionType> = {
159
+ installPath: string;
160
+ installSpec: string;
161
+ pkg: ExtPackageJson<ExtType>;
162
+ installType: InstallType;
163
+ };
164
+
165
+ type InstalledExtensionLike = {installType?: InstallType; installPath?: string};
166
+
167
+ class NotUpdatableError extends Error {}
168
+
169
+ class NoUpdatesAvailableError extends Error {}
170
+
171
+ abstract class ExtensionCliCommand<ExtType extends ExtensionType = ExtensionType> {
59
172
  /**
60
173
  * This is the `DriverConfig` or `PluginConfig`, depending on `ExtType`.
61
- * @type {ExtensionConfig<ExtType>}
62
174
  */
63
- config;
175
+ protected readonly config: ExtensionConfig<ExtType>;
64
176
 
65
177
  /**
66
178
  * {@linkcode Record} of official plugins or drivers.
67
- * @type {KnownExtensions<ExtType>}
68
179
  */
69
- knownExtensions;
180
+ protected knownExtensions: Record<string, string>;
70
181
 
71
182
  /**
72
183
  * If `true`, command output has been requested as JSON.
73
- * @type {boolean}
74
184
  */
75
- isJsonOutput;
185
+ protected readonly isJsonOutput: boolean;
186
+ protected readonly log: any;
76
187
 
77
188
  /**
78
- * Build an ExtensionCommand
79
- * @param {ExtensionCommandOptions<ExtType>} opts
189
+ * Creates an extension command instance.
190
+ *
191
+ * @param opts - constructor options containing extension config and JSON mode
80
192
  */
81
- constructor({config, json}) {
193
+ constructor({config, json}: ExtensionCommandOptions<ExtType>) {
82
194
  this.config = config;
83
195
  this.log = new console.CliConsole({jsonMode: json});
84
196
  this.isJsonOutput = Boolean(json);
@@ -87,32 +199,17 @@ class ExtensionCliCommand {
87
199
  /**
88
200
  * `driver` or `plugin`, depending on the `ExtensionConfig`.
89
201
  */
90
- get type() {
202
+ get type(): ExtensionType {
91
203
  return this.config.extensionType;
92
204
  }
93
205
 
94
206
  /**
95
- * Logs a message and returns an {@linkcode Error} to throw.
96
- *
97
- * For TS to understand that a function throws an exception, it must actually throw an exception--
98
- * in other words, _calling_ a function which is guaranteed to throw an exception is not enough--
99
- * nor is something like `@returns {never}` which does not imply a thrown exception.
100
- *
101
- * @param {string} message
102
- * @protected
103
- * @throws {Error}
104
- */
105
- _createFatalError(message) {
106
- return new Error(this.log.decorate(message, 'error'));
107
- }
108
-
109
- /**
110
- * Take a CLI parse and run an extension command based on its type
207
+ * Executes an extension subcommand from parsed CLI args.
111
208
  *
112
- * @param {object} args - a key/value object with CLI flags and values
113
- * @return {Promise<object>} the result of the specific command which is executed
209
+ * @param args - parsed CLI argument object
210
+ * @returns result of the executed extension subcommand
114
211
  */
115
- async execute(args) {
212
+ async execute(args: Record<string, any>): Promise<unknown> {
116
213
  const cmd = args[`${this.type}Command`];
117
214
  if (!_.isFunction(this[cmd])) {
118
215
  throw this._createFatalError(`Cannot handle ${this.type} command ${cmd}`);
@@ -122,13 +219,12 @@ class ExtensionCliCommand {
122
219
  }
123
220
 
124
221
  /**
125
- * List extensions
222
+ * Lists available/installed extensions and optional update metadata.
126
223
  *
127
- * @template {ExtensionType} ExtType
128
- * @param {ListOptions} opts
129
- * @return {Promise<ExtensionList<ExtType>>} map of extension names to extension data
224
+ * @param opts - list command options
225
+ * @returns map of extension names to list data
130
226
  */
131
- async list({showInstalled, showUpdates, verbose = false}) {
227
+ async list({showInstalled, showUpdates, verbose = false}: ListOptions): Promise<ExtensionList> {
132
228
  const listData = this._buildListData(showInstalled);
133
229
 
134
230
  const lsMsg =
@@ -151,401 +247,126 @@ class ExtensionCliCommand {
151
247
  }
152
248
 
153
249
  /**
154
- * Build the initial list data structure from installed and known extensions
250
+ * Logs a message and returns an {@linkcode Error} to throw.
155
251
  *
156
- * @template {ExtensionType} ExtType
157
- * @param {boolean} showInstalled
158
- * @returns {ExtensionList<ExtType>}
159
- * @private
252
+ * For TS to understand that a function throws an exception, it must actually throw an exception--
253
+ * in other words, _calling_ a function which is guaranteed to throw an exception is not enough--
254
+ * nor is something like a `never` return annotation, which does not imply a thrown exception.
255
+ *
256
+ * @throws {Error}
160
257
  */
161
- _buildListData(showInstalled) {
162
- const installedNames = Object.keys(this.config.installedExtensions);
163
- const knownNames = Object.keys(this.knownExtensions);
164
- return [...installedNames, ...knownNames].reduce((acc, name) => {
165
- if (!acc[name]) {
166
- if (installedNames.includes(name)) {
167
- acc[name] = {
168
- .../** @type {Partial<ExtManifest<ExtType>>} */ (this.config.installedExtensions[name]),
169
- installed: true,
170
- };
171
- } else if (!showInstalled) {
172
- acc[name] = /** @type {ExtensionListData<ExtType>} */ ({
173
- pkgName: this.knownExtensions[name],
174
- installed: false,
175
- });
176
- }
177
- }
178
- return acc;
179
- }, /** @type {ExtensionList<ExtType>} */ ({}));
258
+ protected _createFatalError(message: string): Error {
259
+ return new Error(this.log.decorate(message, 'error'));
180
260
  }
181
261
 
182
262
  /**
183
- * Check for available updates for installed extensions
263
+ * Build the initial list data structure from installed and known extensions
184
264
  *
185
- * @template {ExtensionType} ExtType
186
- * @param {ExtensionList<ExtType>} listData
187
- * @param {boolean} showUpdates
188
- * @param {string} lsMsg
189
- * @returns {Promise<void>}
190
- * @private
191
265
  */
192
- async _checkForUpdates(listData, showUpdates, lsMsg) {
193
- await spinWith(this.isJsonOutput, lsMsg, async () => {
194
- // We'd like to still show lsMsg even if showUpdates is false
195
- if (!showUpdates) {
196
- return;
197
- }
266
+ protected async _install({installSpec, installType, packageName}: InstallOpts): Promise<Record<string, any>> {
267
+ if (packageName && [INSTALL_TYPE_LOCAL, INSTALL_TYPE_NPM].includes(installType)) {
268
+ throw this._createFatalError(`When using --source=${installType}, cannot also use --package`);
269
+ }
198
270
 
199
- // Filter to only extensions that need update checks (installed npm packages)
200
- const extensionsToCheck = _.toPairs(listData).filter(
201
- ([, data]) => data.installed && data.installType === INSTALL_TYPE_NPM
202
- );
271
+ if (!packageName && [INSTALL_TYPE_GIT, INSTALL_TYPE_GITHUB].includes(installType)) {
272
+ throw this._createFatalError(`When using --source=${installType}, must also use --package`);
273
+ }
203
274
 
204
- await B.map(
205
- extensionsToCheck,
206
- async ([ext, data]) => {
207
- try {
208
- const updates = await this.checkForExtensionUpdate(ext);
209
- data.updateVersion = updates.safeUpdate;
210
- data.unsafeUpdateVersion = updates.unsafeUpdate;
211
- data.upToDate = updates.safeUpdate === null && updates.unsafeUpdate === null;
212
- } catch (e) {
213
- data.updateError = e.message;
214
- }
215
- },
216
- {concurrency: MAX_CONCURRENT_REPO_FETCHES}
217
- );
218
- });
219
- }
275
+ let installViaNpmOpts: InstallViaNpmArgs;
220
276
 
221
- /**
222
- * Add repository URLs to list data for all extensions
223
- *
224
- * @template {ExtensionType} ExtType
225
- * @param {ExtensionList<ExtType>} listData
226
- * @returns {Promise<void>}
227
- * @private
228
- */
229
- async _addRepositoryUrlsToListData(listData) {
230
- await spinWith(this.isJsonOutput, 'Fetching repository information', async () => {
231
- await B.map(
232
- _.values(listData),
233
- async (data) => {
234
- const repoUrl = await this._getRepositoryUrl(data);
235
- if (repoUrl) {
236
- data.repositoryUrl = repoUrl;
277
+ /**
278
+ * The probable (?) name of the extension derived from the install spec.
279
+ *
280
+ * If using a local install type, this will remain empty.
281
+ */
282
+ let probableExtName = '';
283
+
284
+ // depending on `installType`, build the options to pass into `installViaNpm`
285
+ if (installType === INSTALL_TYPE_GITHUB) {
286
+ if (installSpec.split('/').length !== 2) {
287
+ throw this._createFatalError(
288
+ `Github ${this.type} spec ${installSpec} appeared to be invalid; ` +
289
+ 'it should be of the form <org>/<repo>'
290
+ );
291
+ }
292
+ installViaNpmOpts = {
293
+ installSpec,
294
+ installType,
295
+ pkgName: packageName as string,
296
+ };
297
+ probableExtName = packageName as string;
298
+ } else if (installType === INSTALL_TYPE_GIT) {
299
+ // git urls can have '.git' at the end, but this is not necessary and would complicate the
300
+ // way we download and name directories, so we can just remove it
301
+ installSpec = installSpec.replace(/\.git$/, '');
302
+ installViaNpmOpts = {
303
+ installSpec,
304
+ installType,
305
+ pkgName: packageName as string,
306
+ };
307
+ probableExtName = packageName as string;
308
+ } else {
309
+ let pkgName: string;
310
+ let pkgVer: string | undefined;
311
+ if (installType === INSTALL_TYPE_LOCAL) {
312
+ pkgName = path.isAbsolute(installSpec) ? installSpec : path.resolve(installSpec);
313
+ } else {
314
+ // at this point we have either an npm package or an appium verified extension
315
+ // name or a local path. both of which will be installed via npm.
316
+ // extensions installed via npm can include versions or tags after the '@'
317
+ // sign, so check for that. We also need to be careful that package names themselves can
318
+ // contain the '@' symbol, as in `npm install @appium/fake-driver@1.2.0`
319
+ let name: string;
320
+ const splits = installSpec.split('@');
321
+ if (installSpec.startsWith('@')) {
322
+ // this is the case where we have an npm org included in the package name
323
+ [name, pkgVer] = [`@${splits[1]}`, splits[2]];
324
+ } else {
325
+ // this is the case without an npm org
326
+ [name, pkgVer] = splits;
327
+ }
328
+
329
+ if (installType === INSTALL_TYPE_NPM) {
330
+ // if we're installing a named package from npm, we don't need to check
331
+ // against the appium extension list; just use the installSpec as is
332
+ pkgName = name;
333
+ } else {
334
+ // if we're installing a named appium driver (like 'xcuitest') we need to
335
+ // dereference the actual npm package ('appiupm-xcuitest-driver'), so
336
+ // check it exists and get the correct package
337
+ const knownNames = Object.keys(this.knownExtensions);
338
+ if (!_.includes(knownNames, name)) {
339
+ const msg =
340
+ `Could not resolve ${this.type}; are you sure it's in the list ` +
341
+ `of supported ${this.type}s? ${JSON.stringify(knownNames)}`;
342
+ throw this._createFatalError(msg);
237
343
  }
238
- },
239
- {concurrency: MAX_CONCURRENT_REPO_FETCHES}
240
- );
241
- });
242
- }
344
+ probableExtName = name;
345
+ pkgName = this.knownExtensions[name];
346
+ // given that we'll use the install type in the driver json, store it as
347
+ // 'npm' now
348
+ installType = INSTALL_TYPE_NPM;
349
+ }
350
+ }
351
+ installViaNpmOpts = {installSpec, pkgName, pkgVer, installType};
352
+ }
243
353
 
244
- /**
245
- * Display normal formatted output
246
- *
247
- * @template {ExtensionType} ExtType
248
- * @param {ExtensionList<ExtType>} listData
249
- * @param {boolean} showUpdates
250
- * @returns {Promise<ExtensionList<ExtType>>}
251
- * @private
252
- */
253
- async _displayNormalListOutput(listData, showUpdates) {
254
- for (const [name, data] of _.toPairs(listData)) {
255
- const line = await this._formatExtensionLine(name, data, showUpdates);
256
- this.log.log(line);
354
+ // fail fast here if we can
355
+ if (probableExtName && this.config.isInstalled(probableExtName)) {
356
+ throw this._createFatalError(
357
+ `A ${this.type} named "${probableExtName}" is already installed. ` +
358
+ `Did you mean to update? Run "appium ${this.type} update". See ` +
359
+ `installed ${this.type}s with "appium ${this.type} list --installed".`
360
+ );
257
361
  }
258
362
 
259
- return listData;
260
- }
363
+ await this._checkInstallCompatibility(installViaNpmOpts);
261
364
 
262
- /**
263
- * Format a single extension line for display
264
- *
265
- * @template {ExtensionType} ExtType
266
- * @param {string} name
267
- * @param {ExtensionListData<ExtType>} data
268
- * @param {boolean} showUpdates
269
- * @returns {Promise<string>}
270
- * @private
271
- */
272
- async _formatExtensionLine(name, data, showUpdates) {
273
- if (data.installed) {
274
- const installTxt = this._formatInstallText(/** @type {InstalledExtensionListData<ExtType>} */ (data));
275
- const updateTxt = showUpdates ? this._formatUpdateText(/** @type {InstalledExtensionListData<ExtType>} */ (data)) : '';
276
- return `- ${name.yellow}${installTxt}${updateTxt}`;
277
- }
278
- const installTxt = ' [not installed]'.grey;
279
- return `- ${name.yellow}${installTxt}`;
280
- }
281
-
282
- /**
283
- * Format installation status text
284
- *
285
- * @template {ExtensionType} ExtType
286
- * @param {InstalledExtensionListData<ExtType>} data
287
- * @returns {string}
288
- * @private
289
- */
290
- _formatInstallText(data) {
291
- const {installType, installSpec, version} = data;
292
- let typeTxt;
293
- switch (installType) {
294
- case INSTALL_TYPE_GIT:
295
- case INSTALL_TYPE_GITHUB:
296
- typeTxt = `(cloned from ${installSpec})`.yellow;
297
- break;
298
- case INSTALL_TYPE_LOCAL:
299
- typeTxt = `(linked from ${installSpec})`.magenta;
300
- break;
301
- case INSTALL_TYPE_DEV:
302
- typeTxt = '(dev mode)';
303
- break;
304
- default:
305
- typeTxt = '(npm)';
306
- }
307
- return `@${version.yellow} ${('[installed ' + typeTxt + ']').green}`;
308
- }
309
-
310
- /**
311
- * Format update information text
312
- *
313
- * @template {ExtensionType} ExtType
314
- * @param {InstalledExtensionListData<ExtType>} data
315
- * @returns {string}
316
- * @private
317
- */
318
- _formatUpdateText(data) {
319
- const {updateVersion, unsafeUpdateVersion, upToDate, updateError} = data;
320
- if (updateError) {
321
- return ` [Cannot check for updates: ${updateError}]`.red;
322
- }
323
- let txt = '';
324
- if (updateVersion) {
325
- txt += ` [${updateVersion} available]`.magenta;
326
- }
327
- if (upToDate) {
328
- txt += ` [Up to date]`.green;
329
- }
330
- if (unsafeUpdateVersion) {
331
- txt += ` [${unsafeUpdateVersion} available (potentially unsafe)]`.cyan;
332
- }
333
- return txt;
334
- }
335
-
336
- /**
337
- * Get repository URL from package data
338
- *
339
- * @template {ExtensionType} ExtType
340
- * @param {ExtensionListData<ExtType>} data
341
- * @returns {Promise<string|null>}
342
- * @private
343
- */
344
- async _getRepositoryUrl(data) {
345
- if (data.installed && data.installPath) {
346
- return await this._getRepositoryUrlFromInstalled(
347
- /** @type {InstalledExtensionListData<ExtType>} */ (data)
348
- );
349
- }
350
- if (data.pkgName && !data.installed) {
351
- return await this._getRepositoryUrlFromNpm(data.pkgName);
352
- }
353
- return null;
354
- }
355
-
356
- /**
357
- * Get repository URL from installed extension's package.json
358
- *
359
- * @template {ExtensionType} ExtType
360
- * @param {InstalledExtensionListData<ExtType>} data
361
- * @returns {Promise<string|null>}
362
- * @private
363
- */
364
- async _getRepositoryUrlFromInstalled(data) {
365
- try {
366
- const pkgJsonPath = path.join(data.installPath, 'package.json');
367
- if (await fs.exists(pkgJsonPath)) {
368
- const pkg = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8'));
369
- if (pkg.repository) {
370
- if (typeof pkg.repository === 'string') {
371
- return pkg.repository;
372
- }
373
- if (pkg.repository.url) {
374
- return pkg.repository.url.replace(/^git\+/, '').replace(/\.git$/, '');
375
- }
376
- }
377
- }
378
- } catch {
379
- // Ignore errors reading package.json
380
- }
381
- return null;
382
- }
383
-
384
- /**
385
- * Get repository URL from npm for a package name
386
- *
387
- * @param {string} pkgName
388
- * @returns {Promise<string|null>}
389
- * @private
390
- */
391
- async _getRepositoryUrlFromNpm(pkgName) {
392
- try {
393
- const repoInfo = await npm.getPackageInfo(pkgName, ['repository']);
394
- // When requesting only 'repository', npm.getPackageInfo returns the repository object directly
395
- if (repoInfo) {
396
- if (typeof repoInfo === 'string') {
397
- return repoInfo;
398
- }
399
- if (repoInfo.url) {
400
- return repoInfo.url.replace(/^git\+/, '').replace(/\.git$/, '');
401
- }
402
- }
403
- } catch {
404
- // Ignore errors fetching from npm
405
- }
406
- return null;
407
- }
408
-
409
- /**
410
- * Checks whether the given extension is compatible with the currently installed server
411
- *
412
- * @param {InstallViaNpmArgs} installViaNpmOpts
413
- * @returns {Promise<void>}
414
- */
415
- async _checkInstallCompatibility({installSpec, pkgName, pkgVer, installType}) {
416
- if (INSTALL_TYPE_NPM !== installType) {
417
- return;
418
- }
419
-
420
- await spinWith(this.isJsonOutput, `Checking if '${pkgName}' is compatible`, async () => {
421
- const [serverVersion, extVersionRequirement] = await getRemoteExtensionVersionReq(pkgName, pkgVer);
422
- if (serverVersion && extVersionRequirement && !semver.satisfies(serverVersion, extVersionRequirement)) {
423
- throw this._createFatalError(
424
- `'${installSpec}' cannot be installed because the server version it requires (${extVersionRequirement}) ` +
425
- `does not meet the currently installed one (${serverVersion}). Please install ` +
426
- `a compatible server version first.`
427
- );
428
- }
429
- });
430
- }
431
-
432
- /**
433
- * Install an extension
434
- *
435
- * @param {InstallOpts} opts
436
- * @return {Promise<ExtRecord<ExtType>>} map of all installed extension names to extension data
437
- */
438
- async _install({installSpec, installType, packageName}) {
439
- /** @type {ExtInstallReceipt<ExtType>} */
440
- let receipt;
441
-
442
- if (packageName && [INSTALL_TYPE_LOCAL, INSTALL_TYPE_NPM].includes(installType)) {
443
- throw this._createFatalError(`When using --source=${installType}, cannot also use --package`);
444
- }
445
-
446
- if (!packageName && [INSTALL_TYPE_GIT, INSTALL_TYPE_GITHUB].includes(installType)) {
447
- throw this._createFatalError(`When using --source=${installType}, must also use --package`);
448
- }
449
-
450
- /**
451
- * @type {InstallViaNpmArgs}
452
- */
453
- let installViaNpmOpts;
454
-
455
- /**
456
- * The probable (?) name of the extension derived from the install spec.
457
- *
458
- * If using a local install type, this will remain empty.
459
- * @type {string}
460
- */
461
- let probableExtName = '';
462
-
463
- // depending on `installType`, build the options to pass into `installViaNpm`
464
- if (installType === INSTALL_TYPE_GITHUB) {
465
- if (installSpec.split('/').length !== 2) {
466
- throw this._createFatalError(
467
- `Github ${this.type} spec ${installSpec} appeared to be invalid; ` +
468
- 'it should be of the form <org>/<repo>'
469
- );
470
- }
471
- installViaNpmOpts = {
472
- installSpec,
473
- installType,
474
- pkgName: /** @type {string} */ (packageName),
475
- };
476
- probableExtName = /** @type {string} */ (packageName);
477
- } else if (installType === INSTALL_TYPE_GIT) {
478
- // git urls can have '.git' at the end, but this is not necessary and would complicate the
479
- // way we download and name directories, so we can just remove it
480
- installSpec = installSpec.replace(/\.git$/, '');
481
- installViaNpmOpts = {
482
- installSpec,
483
- installType,
484
- pkgName: /** @type {string} */ (packageName),
485
- };
486
- probableExtName = /** @type {string} */ (packageName);
487
- } else {
488
- let pkgName, pkgVer;
489
- if (installType === INSTALL_TYPE_LOCAL) {
490
- pkgName = path.isAbsolute(installSpec) ? installSpec : path.resolve(installSpec);
491
- } else {
492
- // at this point we have either an npm package or an appium verified extension
493
- // name or a local path. both of which will be installed via npm.
494
- // extensions installed via npm can include versions or tags after the '@'
495
- // sign, so check for that. We also need to be careful that package names themselves can
496
- // contain the '@' symbol, as in `npm install @appium/fake-driver@1.2.0`
497
- let name;
498
- const splits = installSpec.split('@');
499
- if (installSpec.startsWith('@')) {
500
- // this is the case where we have an npm org included in the package name
501
- [name, pkgVer] = [`@${splits[1]}`, splits[2]];
502
- } else {
503
- // this is the case without an npm org
504
- [name, pkgVer] = splits;
505
- }
506
-
507
- if (installType === INSTALL_TYPE_NPM) {
508
- // if we're installing a named package from npm, we don't need to check
509
- // against the appium extension list; just use the installSpec as is
510
- pkgName = name;
511
- } else {
512
- // if we're installing a named appium driver (like 'xcuitest') we need to
513
- // dereference the actual npm package ('appiupm-xcuitest-driver'), so
514
- // check it exists and get the correct package
515
- const knownNames = Object.keys(this.knownExtensions);
516
- if (!_.includes(knownNames, name)) {
517
- const msg =
518
- `Could not resolve ${this.type}; are you sure it's in the list ` +
519
- `of supported ${this.type}s? ${JSON.stringify(knownNames)}`;
520
- throw this._createFatalError(msg);
521
- }
522
- probableExtName = name;
523
- pkgName = this.knownExtensions[name];
524
- // given that we'll use the install type in the driver json, store it as
525
- // 'npm' now
526
- installType = INSTALL_TYPE_NPM;
527
- }
528
- }
529
- installViaNpmOpts = {installSpec, pkgName, pkgVer, installType};
530
- }
531
-
532
- // fail fast here if we can
533
- if (probableExtName && this.config.isInstalled(probableExtName)) {
534
- throw this._createFatalError(
535
- `A ${this.type} named "${probableExtName}" is already installed. ` +
536
- `Did you mean to update? Run "appium ${this.type} update". See ` +
537
- `installed ${this.type}s with "appium ${this.type} list --installed".`
538
- );
539
- }
540
-
541
- await this._checkInstallCompatibility(installViaNpmOpts);
542
-
543
- receipt = await this.installViaNpm(installViaNpmOpts);
365
+ const receipt = await this.installViaNpm(installViaNpmOpts);
544
366
 
545
367
  // this _should_ be the same as `probablyExtName` as the one derived above unless
546
368
  // install type is local.
547
- /** @type {string} */
548
- const extName = receipt[/** @type {string} */ (`${this.type}Name`)];
369
+ const extName = receipt[`${this.type}Name`];
549
370
 
550
371
  // check _a second time_ with the more-accurate extName
551
372
  if (this.config.isInstalled(extName)) {
@@ -558,12 +379,11 @@ class ExtensionCliCommand {
558
379
 
559
380
  // this field does not exist as such in the manifest (it's used as a property name instead)
560
381
  // so that's why it's being removed here.
561
- /** @type {ExtManifest<ExtType>} */
562
382
  const extManifest = receiptToManifest(receipt);
563
383
 
564
384
  const [errors, warnings] = await B.all([
565
- this.config.getProblems(extName, extManifest),
566
- this.config.getWarnings(extName, extManifest),
385
+ this.config.getProblems(extName, extManifest as any),
386
+ this.config.getWarnings(extName, extManifest as any),
567
387
  ]);
568
388
  const errorMap = new Map([[extName, errors]]);
569
389
  const warningMap = new Map([[extName, warnings]]);
@@ -581,7 +401,7 @@ class ExtensionCliCommand {
581
401
  this.log.warn(warningSummaries.join('\n'));
582
402
  }
583
403
 
584
- await this.config.addExtension(extName, extManifest);
404
+ await this.config.addExtension(extName, extManifest as any);
585
405
 
586
406
  // update the hash if we've changed the local `package.json`
587
407
  if (await env.hasAppiumDependency(this.config.appiumHome)) {
@@ -589,178 +409,47 @@ class ExtensionCliCommand {
589
409
  }
590
410
 
591
411
  // log info for the user
592
- this.log.info(this.getPostInstallText({extName, extData: receipt}));
412
+ this.log.info(
413
+ this.getPostInstallText({extName, extData: receipt as unknown as ExtInstallReceipt<ExtType>})
414
+ );
593
415
 
594
416
  return this.config.installedExtensions;
595
417
  }
596
418
 
597
419
  /**
598
- * Install an extension via NPM
420
+ * Uninstall an extension.
421
+ *
422
+ * First tries to do this via `npm uninstall`, but if that fails, just `rm -rf`'s the extension dir.
599
423
  *
600
- * @param {InstallViaNpmArgs} args
601
- * @returns {Promise<ExtInstallReceipt<ExtType>>}
424
+ * Will only remove the extension from the manifest if it has been successfully removed.
425
+ *
426
+ * @return map of all installed extension names to extension data (without the extension just uninstalled)
602
427
  */
603
- async installViaNpm({installSpec, pkgName, pkgVer, installType}) {
604
- const installMsg = `Installing '${installSpec}'`;
605
- const validateMsg = `Validating '${installSpec}'`;
606
-
607
- // the string used for installation is either <name>@<ver> in the case of a standard NPM
608
- // package, or whatever the user sent in otherwise.
609
- const installStr = installType === INSTALL_TYPE_NPM ? `${pkgName}${pkgVer ? `@${pkgVer}` : ''}` : installSpec;
610
- const appiumHome = this.config.appiumHome;
611
- try {
612
- const {pkg, installPath} = await spinWith(
613
- this.isJsonOutput,
614
- installMsg,
615
- async () => await npm.installPackage(appiumHome, installStr, {pkgName, installType})
428
+ protected async _uninstall({installSpec}: UninstallOpts): Promise<Record<string, any>> {
429
+ if (!this.config.isInstalled(installSpec)) {
430
+ throw this._createFatalError(
431
+ `Can't uninstall ${this.type} '${installSpec}'; it is not installed`
616
432
  );
617
-
618
- await spinWith(this.isJsonOutput, validateMsg, async () => {
619
- this.validatePackageJson(pkg, installSpec);
620
- });
621
-
622
- return this.getInstallationReceipt({
623
- pkg,
624
- installPath,
625
- installType,
626
- installSpec,
627
- });
628
- } catch (err) {
629
- throw this._createFatalError(`Encountered an error when installing package: ${err.message}`);
630
433
  }
631
- }
632
-
633
- /**
634
- * Get the text which should be displayed to the user after an extension has been installed. This
635
- * is designed to be overridden by drivers/plugins with their own particular text.
636
- *
637
- * @param {ExtensionArgs} args
638
- * @returns {string}
639
- */
640
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
641
- getPostInstallText(args) {
642
- throw this._createFatalError('Must be implemented in final class');
643
- }
644
-
645
- /**
646
- * Once a package is installed on-disk, this gathers some necessary metadata for validation.
647
- *
648
- * @param {GetInstallationReceiptOpts<ExtType>} opts
649
- * @returns {ExtInstallReceipt<ExtType>}
650
- */
651
- getInstallationReceipt({pkg, installPath, installType, installSpec}) {
652
- const {appium, name, version, peerDependencies} = pkg;
653
-
654
- const strVersion = /** @type {string} */ (version);
655
- /** @type {import('appium/types').InternalMetadata} */
656
- const internal = {
657
- pkgName: /** @type {string} */ (name),
658
- version: strVersion,
659
- installType,
660
- installSpec,
661
- installPath,
662
- appiumVersion: peerDependencies?.appium,
663
- };
664
-
665
- /** @type {ExtMetadata<ExtType>} */
666
- const extMetadata = appium;
667
-
668
- return {
669
- ...internal,
670
- ...extMetadata,
671
- };
672
- }
673
-
674
- /**
675
- * Validates the _required_ root fields of an extension's `package.json` file.
676
- *
677
- * These required fields are:
678
- * - `name`
679
- * - `version`
680
- * - `appium`
681
- * @param {import('type-fest').PackageJson} pkg - `package.json` of extension
682
- * @param {string} installSpec - Extension name/spec
683
- * @throws {ReferenceError} If `package.json` has a missing or invalid field
684
- * @returns {pkg is ExtPackageJson<ExtType>}
685
- */
686
- validatePackageJson(pkg, installSpec) {
687
- const {appium, name, version} = /** @type {ExtPackageJson<ExtType>} */ (pkg);
688
-
689
- /**
690
- *
691
- * @param {string} field
692
- * @returns {ReferenceError}
693
- */
694
- const createMissingFieldError = (field) =>
695
- new ReferenceError(
696
- `${this.type} "${installSpec}" invalid; missing a \`${field}\` field of its \`package.json\``
697
- );
698
-
699
- if (!name) {
700
- throw createMissingFieldError('name');
701
- }
702
- if (!version) {
703
- throw createMissingFieldError('version');
704
- }
705
- if (!appium) {
706
- throw createMissingFieldError('appium');
707
- }
708
-
709
- this.validateExtensionFields(appium, installSpec);
710
-
711
- return true;
712
- }
713
-
714
- /**
715
- * For any `package.json` fields which a particular type of extension requires, validate the
716
- * presence and form of those fields on the `package.json` data, throwing an error if anything is
717
- * amiss.
718
- *
719
- * @param {ExtMetadata<ExtType>} extMetadata - the data in the "appium" field of `package.json` for an extension
720
- * @param {string} installSpec - Extension name/spec
721
- */
722
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
723
- validateExtensionFields(extMetadata, installSpec) {
724
- throw this._createFatalError('Must be implemented in final class');
725
- }
726
-
727
- /**
728
- * Uninstall an extension.
729
- *
730
- * First tries to do this via `npm uninstall`, but if that fails, just `rm -rf`'s the extension dir.
731
- *
732
- * Will only remove the extension from the manifest if it has been successfully removed.
733
- *
734
- * @param {UninstallOpts} opts
735
- * @return {Promise<ExtRecord<ExtType>>} map of all installed extension names to extension data (without the extension just uninstalled)
736
- */
737
- async _uninstall({installSpec}) {
738
- if (!this.config.isInstalled(installSpec)) {
739
- throw this._createFatalError(
740
- `Can't uninstall ${this.type} '${installSpec}'; it is not installed`
741
- );
742
- }
743
- const extRecord = this.config.installedExtensions[installSpec];
744
- if (extRecord.installType === INSTALL_TYPE_DEV) {
745
- this.log.warn(`Cannot uninstall ${this.type} "${installSpec}" because it is in development!`);
746
- return this.config.installedExtensions;
747
- }
748
- const pkgName = extRecord.pkgName;
749
- await spinWith(this.isJsonOutput, `Uninstalling ${this.type} '${installSpec}'`, async () => {
750
- await npm.uninstallPackage(this.config.appiumHome, pkgName);
751
- });
752
- await this.config.removeExtension(installSpec);
753
- this.log.ok(`Successfully uninstalled ${this.type} '${installSpec}'`.green);
754
- return this.config.installedExtensions;
434
+ const extRecord = this.config.installedExtensions[installSpec];
435
+ if (extRecord.installType === INSTALL_TYPE_DEV) {
436
+ this.log.warn(`Cannot uninstall ${this.type} "${installSpec}" because it is in development!`);
437
+ return this.config.installedExtensions;
438
+ }
439
+ const pkgName = extRecord.pkgName;
440
+ await spinWith(this.isJsonOutput, `Uninstalling ${this.type} '${installSpec}'`, async () => {
441
+ await npm.uninstallPackage(this.config.appiumHome, pkgName);
442
+ });
443
+ await this.config.removeExtension(installSpec);
444
+ this.log.ok(`Successfully uninstalled ${this.type} '${installSpec}'`.green);
445
+ return this.config.installedExtensions;
755
446
  }
756
447
 
757
448
  /**
758
449
  * Attempt to update one or more drivers using NPM
759
450
  *
760
- * @param {ExtensionUpdateOpts} updateSpec
761
- * @return {Promise<ExtensionUpdateResult>}
762
451
  */
763
- async _update({installSpec, unsafe}) {
452
+ protected async _update({installSpec, unsafe}: ExtensionUpdateOpts): Promise<ExtensionUpdateResult> {
764
453
  const shouldUpdateAll = installSpec === UPDATE_ALL;
765
454
  // if we're specifically requesting an update for an extension, make sure it's installed
766
455
  if (!shouldUpdateAll && !this.config.isInstalled(installSpec)) {
@@ -773,13 +462,11 @@ class ExtensionCliCommand {
773
462
  : [installSpec];
774
463
 
775
464
  // 'errors' will have ext names as keys and error objects as values
776
- /** @type {Record<string,Error>} */
777
- const errors = {};
465
+ const errors: Record<string, Error> = {};
778
466
 
779
467
  // 'updates' will have ext names as keys and update objects as values, where an update
780
468
  // object is of the form {from: versionString, to: versionString}
781
- /** @type {Record<string,UpdateReport>} */
782
- const updates = {};
469
+ const updates: Record<string, UpdateReport> = {};
783
470
 
784
471
  for (const e of extsToUpdate) {
785
472
  try {
@@ -807,6 +494,9 @@ class ExtensionCliCommand {
807
494
  );
808
495
  }
809
496
  const updateVer = unsafe && update.unsafeUpdate ? update.unsafeUpdate : update.safeUpdate;
497
+ if (!updateVer) {
498
+ throw new NoUpdatesAvailableError();
499
+ }
810
500
  await spinWith(
811
501
  this.isJsonOutput,
812
502
  `Updating ${this.type} '${e}' from ${update.current} to ${updateVer}`,
@@ -850,15 +540,13 @@ class ExtensionCliCommand {
850
540
  * Given an extension name, figure out what its highest possible version upgrade is, and also the
851
541
  * highest possible safe upgrade.
852
542
  *
853
- * @param {string} ext - name of extension
854
- * @return {Promise<PossibleUpdates>}
543
+ * @param ext - name of extension
855
544
  */
856
- async checkForExtensionUpdate(ext) {
545
+ protected async checkForExtensionUpdate(ext: string): Promise<PossibleUpdates> {
857
546
  // TODO decide how we want to handle beta versions?
858
547
  // this is a helper method, 'ext' is assumed to already be installed here, and of the npm
859
548
  // install type
860
549
  const {version, pkgName} = this.config.installedExtensions[ext];
861
- /** @type {string?} */
862
550
  let unsafeUpdate = await npm.getLatestVersion(this.config.appiumHome, pkgName);
863
551
  let safeUpdate = await npm.getLatestSafeUpgradeVersion(
864
552
  this.config.appiumHome,
@@ -881,53 +569,14 @@ class ExtensionCliCommand {
881
569
  return {current: version, safeUpdate, unsafeUpdate};
882
570
  }
883
571
 
884
- /**
885
- * Actually update an extension installed by NPM, using the NPM cli. And update the installation
886
- * manifest.
887
- *
888
- * @param {string} installSpec - name of extension to update
889
- * @param {string} version - version string identifier to update extension to
890
- * @returns {Promise<void>}
891
- */
892
- async updateExtension(installSpec, version) {
893
- const {pkgName, installType} = this.config.installedExtensions[installSpec];
894
- const extData = await this.installViaNpm({
895
- installSpec,
896
- installType,
897
- pkgName,
898
- pkgVer: version,
899
- });
900
-
901
- delete extData[/** @type {string} */ (`${this.type}Name`)];
902
- await this.config.updateExtension(installSpec, extData);
903
- }
904
-
905
- /**
906
- * Just wraps {@linkcode child_process.spawn} with some default options
907
- *
908
- * @param {string} cwd - CWD
909
- * @param {string} script - Path to script
910
- * @param {string[]} args - Extra args for script
911
- * @param {import('child_process').SpawnOptions} opts - Options
912
- * @returns {import('node:child_process').ChildProcess}
913
- */
914
- _runUnbuffered(cwd, script, args = [], opts = {}) {
915
- return spawn(process.execPath, [script, ...args], {
916
- cwd,
917
- stdio: 'inherit',
918
- ...opts,
919
- });
920
- }
921
-
922
572
  /**
923
573
  * Runs doctor checks for the given extension.
924
574
  *
925
- * @param {DoctorOptions} opts
926
- * @returns {Promise<number>} The amount of Doctor checks that were
575
+ * @returns The amount of Doctor checks that were
927
576
  * successfully loaded and executed for the given extension
928
577
  * @throws {Error} If any of the mandatory Doctor checks fails.
929
578
  */
930
- async _doctor({installSpec}) {
579
+ protected async _doctor({installSpec}: DoctorOptions): Promise<number> {
931
580
  if (!this.config.isInstalled(installSpec)) {
932
581
  throw this._createFatalError(`The ${this.type} "${installSpec}" is not installed`);
933
582
  }
@@ -939,7 +588,7 @@ class ExtensionCliCommand {
939
588
  `No package.json could be found for "${installSpec}" ${this.type}`
940
589
  );
941
590
  }
942
- let doctorSpec;
591
+ let doctorSpec: {checks: string[]} | undefined;
943
592
  try {
944
593
  doctorSpec = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')).appium?.doctor;
945
594
  } catch (e) {
@@ -957,7 +606,8 @@ class ExtensionCliCommand {
957
606
  `containing the 'checks' key with the array of script paths`
958
607
  );
959
608
  }
960
- const paths = doctorSpec.checks.map((/** @type {string} */ p) => {
609
+ const paths: string[] = doctorSpec.checks
610
+ .map((p) => {
961
611
  const scriptPath = path.resolve(moduleRoot, p);
962
612
  if (!path.normalize(scriptPath).startsWith(path.normalize(moduleRoot))) {
963
613
  this.log.error(
@@ -967,9 +617,9 @@ class ExtensionCliCommand {
967
617
  return null;
968
618
  }
969
619
  return scriptPath;
970
- }).filter(Boolean);
971
- /** @type {Promise[]} */
972
- const loadChecksPromises = [];
620
+ })
621
+ .filter((p): p is string => Boolean(p));
622
+ const loadChecksPromises: Promise<unknown>[] = [];
973
623
  for (const p of paths) {
974
624
  const promise = (async () => {
975
625
  // https://github.com/nodejs/node/issues/31710
@@ -982,12 +632,11 @@ class ExtensionCliCommand {
982
632
  })();
983
633
  loadChecksPromises.push(promise);
984
634
  }
985
- const isDoctorCheck = (/** @type {any} */ x) =>
635
+ const isDoctorCheck = (x) =>
986
636
  ['diagnose', 'fix', 'hasAutofix', 'isOptional'].every((method) => _.isFunction(x?.[method]));
987
- /** @type {import('@appium/types').IDoctorCheck[]} */
988
- const checks = _.flatMap((await B.all(loadChecksPromises)).filter(Boolean).map(_.toPairs))
637
+ const checks: IDoctorCheck[] = _.flatMap((await B.all(loadChecksPromises)).filter(Boolean).map(_.toPairs))
989
638
  .map(([, value]) => value)
990
- .filter(isDoctorCheck);
639
+ .filter(isDoctorCheck) as IDoctorCheck[];
991
640
  if (_.isEmpty(checks)) {
992
641
  this.log.info(`The ${this.type} "${installSpec}" exports no valid doctor checks`);
993
642
  return 0;
@@ -1011,10 +660,13 @@ class ExtensionCliCommand {
1011
660
  * `scripts` field is not a plain object, or if the `scriptName` is
1012
661
  * not found within `scripts` object.
1013
662
  *
1014
- * @param {RunOptions} opts
1015
- * @return {Promise<RunOutput>}
1016
663
  */
1017
- async _run({installSpec, scriptName, extraArgs = [], bufferOutput = false}) {
664
+ protected async _run({
665
+ installSpec,
666
+ scriptName,
667
+ extraArgs = [],
668
+ bufferOutput = false,
669
+ }: RunOptions): Promise<RunOutput> {
1018
670
  if (!this.config.isInstalled(installSpec)) {
1019
671
  throw this._createFatalError(`The ${this.type} "${installSpec}" is not installed`);
1020
672
  }
@@ -1038,7 +690,7 @@ class ExtensionCliCommand {
1038
690
  }
1039
691
 
1040
692
  if (!scriptName) {
1041
- const allScripts = _.toPairs(extScripts);
693
+ const allScripts = _.toPairs(extScripts as Record<string, string>);
1042
694
  const root = this.config.getInstallPath(installSpec);
1043
695
  const existingScripts = await B.filter(
1044
696
  allScripts,
@@ -1054,86 +706,510 @@ class ExtensionCliCommand {
1054
706
  this.log.ok(`Successfully retrieved the list of scripts`.green);
1055
707
  return {};
1056
708
  }
1057
-
1058
- if (!(scriptName in /** @type {Record<string,string>} */ (extScripts))) {
1059
- throw this._createFatalError(
1060
- `The ${this.type} named '${installSpec}' does not support the script: '${scriptName}'`
1061
- );
709
+
710
+ if (!(scriptName in extScripts)) {
711
+ throw this._createFatalError(
712
+ `The ${this.type} named '${installSpec}' does not support the script: '${scriptName}'`
713
+ );
714
+ }
715
+
716
+ const scriptPath = extScripts[scriptName];
717
+ const moduleRoot = this.config.getInstallPath(installSpec);
718
+ const normalizedScriptPath = path.normalize(path.resolve(moduleRoot, scriptPath));
719
+ if (!normalizedScriptPath.startsWith(path.normalize(moduleRoot))) {
720
+ throw this._createFatalError(
721
+ `The '${scriptPath}' script must be located in the '${moduleRoot}' folder`
722
+ );
723
+ }
724
+
725
+ if (bufferOutput) {
726
+ const runner = new SubProcess(process.execPath, [scriptPath, ...extraArgs], {
727
+ cwd: moduleRoot,
728
+ });
729
+
730
+ const output = new RingBuffer(50);
731
+
732
+ runner.on('stream-line', (line) => {
733
+ output.enqueue(line);
734
+ this.log.log(line);
735
+ });
736
+
737
+ await runner.start(0);
738
+
739
+ try {
740
+ await runner.join();
741
+ this.log.ok(`${scriptName} successfully ran`.green);
742
+ return {output: output.getBuff()};
743
+ } catch (err) {
744
+ const message = `Encountered an error when running '${scriptName}': ${err.message}`;
745
+ throw this._createFatalError(message);
746
+ }
747
+ }
748
+
749
+ try {
750
+ await new B((resolve, reject) => {
751
+ this._runUnbuffered(moduleRoot, scriptPath, extraArgs)
752
+ .once('error', (err) => {
753
+ // generally this is of the "I can't find the script" variety.
754
+ // this is a developer bug: the extension is pointing to a script that is not where the
755
+ // developer said it would be (in `appium.scripts` of the extension's `package.json`)
756
+ reject(err);
757
+ })
758
+ .once('close', (code) => {
759
+ if (code === 0) {
760
+ resolve();
761
+ } else {
762
+ reject(new Error(`Script exited with code ${code}`));
763
+ }
764
+ });
765
+ });
766
+ this.log.ok(`${scriptName} successfully ran`.green);
767
+ return {};
768
+ } catch (err) {
769
+ const message = `Encountered an error when running '${scriptName}': ${err.message}`;
770
+ throw this._createFatalError(message);
771
+ }
772
+ }
773
+
774
+ private _buildListData(showInstalled: boolean): ExtensionList {
775
+ const installedNames = Object.keys(this.config.installedExtensions);
776
+ const knownNames = Object.keys(this.knownExtensions);
777
+ return [...installedNames, ...knownNames].reduce((acc, name) => {
778
+ if (!acc[name]) {
779
+ if (installedNames.includes(name)) {
780
+ acc[name] = {
781
+ ...this.config.installedExtensions[name],
782
+ installed: true,
783
+ };
784
+ } else if (!showInstalled) {
785
+ acc[name] = {
786
+ pkgName: this.knownExtensions[name],
787
+ installed: false,
788
+ };
789
+ }
790
+ }
791
+ return acc;
792
+ }, {});
793
+ }
794
+
795
+ /**
796
+ * Install an extension via NPM
797
+ *
798
+ */
799
+ private async installViaNpm({
800
+ installSpec,
801
+ pkgName,
802
+ pkgVer,
803
+ installType,
804
+ }: InstallViaNpmArgs): Promise<ExtInstallReceipt<ExtType>> {
805
+ const installMsg = `Installing '${installSpec}'`;
806
+ const validateMsg = `Validating '${installSpec}'`;
807
+
808
+ // the string used for installation is either <name>@<ver> in the case of a standard NPM
809
+ // package, or whatever the user sent in otherwise.
810
+ const installStr = installType === INSTALL_TYPE_NPM ? `${pkgName}${pkgVer ? `@${pkgVer}` : ''}` : installSpec;
811
+ const appiumHome = this.config.appiumHome;
812
+ try {
813
+ const {pkg, installPath} = await spinWith(
814
+ this.isJsonOutput,
815
+ installMsg,
816
+ async () => await npm.installPackage(appiumHome, installStr, {pkgName, installType})
817
+ );
818
+
819
+ const validatedPkg = await spinWith(this.isJsonOutput, validateMsg, async () =>
820
+ this.validatePackageJson(pkg, installSpec)
821
+ );
822
+
823
+ return this.getInstallationReceipt({
824
+ pkg: validatedPkg,
825
+ installPath,
826
+ installType,
827
+ installSpec,
828
+ });
829
+ } catch (err) {
830
+ throw this._createFatalError(`Encountered an error when installing package: ${err.message}`);
831
+ }
832
+ }
833
+
834
+
835
+ /**
836
+ * Actually update an extension installed by NPM, using the NPM cli. And update the installation
837
+ * manifest.
838
+ *
839
+ * @param installSpec - name of extension to update
840
+ * @param version - version string identifier to update extension to
841
+ */
842
+ private async updateExtension(installSpec: string, version: string): Promise<void> {
843
+ const {pkgName, installType} = this.config.installedExtensions[installSpec];
844
+ const extData = await this.installViaNpm({
845
+ installSpec,
846
+ installType,
847
+ pkgName,
848
+ pkgVer: version,
849
+ });
850
+
851
+ delete extData[`${this.type}Name`];
852
+ await this.config.updateExtension(installSpec, extData as any);
853
+ }
854
+
855
+ /**
856
+ * Just wraps {@linkcode child_process.spawn} with some default options
857
+ *
858
+ * @param cwd - CWD
859
+ * @param script - Path to script
860
+ * @param args - Extra args for script
861
+ * @param opts - Options
862
+ */
863
+ private _runUnbuffered(
864
+ cwd: string,
865
+ script: string,
866
+ args: string[] = [],
867
+ opts: Record<string, any> = {}
868
+ ) {
869
+ return spawn(process.execPath, [script, ...args], {
870
+ cwd,
871
+ stdio: 'inherit',
872
+ ...opts,
873
+ });
874
+ }
875
+
876
+ /**
877
+ * Once a package is installed on-disk, this gathers some necessary metadata for validation.
878
+ *
879
+ */
880
+ private getInstallationReceipt({
881
+ pkg,
882
+ installPath,
883
+ installType,
884
+ installSpec,
885
+ }: GetInstallationReceiptOpts<ExtType>): ExtInstallReceipt<ExtType> {
886
+ const {appium, name, version, peerDependencies} = pkg;
887
+
888
+ const strVersion = version;
889
+ const internal = {
890
+ pkgName: name,
891
+ version: strVersion,
892
+ installType,
893
+ installSpec,
894
+ installPath,
895
+ appiumVersion: peerDependencies?.appium,
896
+ };
897
+
898
+ const extMetadata = appium;
899
+
900
+ return {
901
+ ...internal,
902
+ ...extMetadata,
903
+ } as unknown as ExtInstallReceipt<ExtType>;
904
+ }
905
+
906
+ /**
907
+ * Validates the _required_ root fields of an extension's `package.json` file.
908
+ *
909
+ * These required fields are:
910
+ * - `name`
911
+ * - `version`
912
+ * - `appium`
913
+ * @param pkg - `package.json` of extension
914
+ * @param installSpec - Extension name/spec
915
+ * @throws {ReferenceError} If `package.json` has a missing or invalid field
916
+ */
917
+ private validatePackageJson(pkg: PackageJson, installSpec: string): ExtPackageJson<ExtType> {
918
+ const {appium, name, version} = pkg;
919
+
920
+ const createMissingFieldError = (field: string): ReferenceError =>
921
+ new ReferenceError(
922
+ `${this.type} "${installSpec}" invalid; missing a \`${field}\` field of its \`package.json\``
923
+ );
924
+
925
+ if (!name) {
926
+ throw createMissingFieldError('name');
927
+ }
928
+ if (!version) {
929
+ throw createMissingFieldError('version');
930
+ }
931
+ if (!appium) {
932
+ throw createMissingFieldError('appium');
933
+ }
934
+
935
+ this.validateExtensionFields(appium as unknown as ExtMetadata<ExtType>, installSpec);
936
+
937
+ return pkg as unknown as ExtPackageJson<ExtType>;
938
+ }
939
+
940
+ /**
941
+ * Check for available updates for installed extensions
942
+ *
943
+ */
944
+ private async _checkForUpdates(
945
+ listData: ExtensionList,
946
+ showUpdates: boolean,
947
+ lsMsg: string
948
+ ): Promise<void> {
949
+ await spinWith(this.isJsonOutput, lsMsg, async () => {
950
+ // We'd like to still show lsMsg even if showUpdates is false
951
+ if (!showUpdates) {
952
+ return;
953
+ }
954
+
955
+ // Filter to only extensions that need update checks (installed npm packages)
956
+ const extensionsToCheck = _.toPairs(listData as Record<string, any>).filter(
957
+ ([, data]) => data.installed && data.installType === INSTALL_TYPE_NPM
958
+ );
959
+
960
+ await B.map(
961
+ extensionsToCheck,
962
+ async ([ext, data]) => {
963
+ try {
964
+ const updates = await this.checkForExtensionUpdate(ext);
965
+ data.updateVersion = updates.safeUpdate;
966
+ data.unsafeUpdateVersion = updates.unsafeUpdate;
967
+ data.upToDate = updates.safeUpdate === null && updates.unsafeUpdate === null;
968
+ } catch (e) {
969
+ data.updateError = (e as Error).message;
970
+ }
971
+ },
972
+ {concurrency: MAX_CONCURRENT_REPO_FETCHES}
973
+ );
974
+ });
975
+ }
976
+
977
+ /**
978
+ * Add repository URLs to list data for all extensions
979
+ *
980
+ */
981
+ private async _addRepositoryUrlsToListData(listData: ExtensionList): Promise<void> {
982
+ await spinWith(this.isJsonOutput, 'Fetching repository information', async () => {
983
+ await B.map(
984
+ _.values(listData),
985
+ async (data) => {
986
+ const repoUrl = await this._getRepositoryUrl(data);
987
+ if (repoUrl) {
988
+ data.repositoryUrl = repoUrl;
989
+ }
990
+ },
991
+ {concurrency: MAX_CONCURRENT_REPO_FETCHES}
992
+ );
993
+ });
994
+ }
995
+
996
+ /**
997
+ * Display normal formatted output
998
+ *
999
+ */
1000
+ private async _displayNormalListOutput(
1001
+ listData: ExtensionList,
1002
+ showUpdates: boolean
1003
+ ): Promise<ExtensionList> {
1004
+ for (const [name, data] of _.toPairs(listData)) {
1005
+ const line = await this._formatExtensionLine(name, data, showUpdates);
1006
+ this.log.log(line);
1007
+ }
1008
+
1009
+ return listData;
1010
+ }
1011
+
1012
+ /**
1013
+ * Format a single extension line for display
1014
+ *
1015
+ */
1016
+ private async _formatExtensionLine(
1017
+ name: string,
1018
+ data: ExtensionListData,
1019
+ showUpdates: boolean
1020
+ ): Promise<string> {
1021
+ if (data.installed) {
1022
+ const installTxt = this._formatInstallText(data);
1023
+ const updateTxt = showUpdates ? this._formatUpdateText(data) : '';
1024
+ return `- ${name.yellow}${installTxt}${updateTxt}`;
1025
+ }
1026
+ const installTxt = ' [not installed]'.grey;
1027
+ return `- ${name.yellow}${installTxt}`;
1028
+ }
1029
+
1030
+ /**
1031
+ * Format installation status text
1032
+ *
1033
+ */
1034
+ private _formatInstallText(data: ExtensionListData): string {
1035
+ const {installType, installSpec, version} = data;
1036
+ let typeTxt;
1037
+ switch (installType) {
1038
+ case INSTALL_TYPE_GIT:
1039
+ case INSTALL_TYPE_GITHUB:
1040
+ typeTxt = `(cloned from ${installSpec})`.yellow;
1041
+ break;
1042
+ case INSTALL_TYPE_LOCAL:
1043
+ typeTxt = `(linked from ${installSpec})`.magenta;
1044
+ break;
1045
+ case INSTALL_TYPE_DEV:
1046
+ typeTxt = '(dev mode)';
1047
+ break;
1048
+ default:
1049
+ typeTxt = '(npm)';
1050
+ }
1051
+ return `@${String(version).yellow} ${('[installed ' + typeTxt + ']').green}`;
1052
+ }
1053
+
1054
+ /**
1055
+ * Format update information text
1056
+ *
1057
+ */
1058
+ private _formatUpdateText(data: ExtensionListData): string {
1059
+ const {updateVersion, unsafeUpdateVersion, upToDate, updateError} = data;
1060
+ if (updateError) {
1061
+ return ` [Cannot check for updates: ${updateError}]`.red;
1062
+ }
1063
+ let txt = '';
1064
+ if (updateVersion) {
1065
+ txt += ` [${updateVersion} available]`.magenta;
1066
+ }
1067
+ if (upToDate) {
1068
+ txt += ` [Up to date]`.green;
1069
+ }
1070
+ if (unsafeUpdateVersion) {
1071
+ txt += ` [${unsafeUpdateVersion} available (potentially unsafe)]`.cyan;
1062
1072
  }
1073
+ return txt;
1074
+ }
1063
1075
 
1064
- const scriptPath = extScripts[scriptName];
1065
- const moduleRoot = this.config.getInstallPath(installSpec);
1066
- const normalizedScriptPath = path.normalize(path.resolve(moduleRoot, scriptPath));
1067
- if (!normalizedScriptPath.startsWith(path.normalize(moduleRoot))) {
1068
- throw this._createFatalError(
1069
- `The '${scriptPath}' script must be located in the '${moduleRoot}' folder`
1076
+ /**
1077
+ * Get repository URL from package data
1078
+ *
1079
+ */
1080
+ private async _getRepositoryUrl(data: ExtensionListData): Promise<string | null> {
1081
+ if (data.installed && data.installPath) {
1082
+ return await this._getRepositoryUrlFromInstalled(
1083
+ data
1070
1084
  );
1071
1085
  }
1086
+ if (data.pkgName && !data.installed) {
1087
+ return await this._getRepositoryUrlFromNpm(data.pkgName);
1088
+ }
1089
+ return null;
1090
+ }
1072
1091
 
1073
- if (bufferOutput) {
1074
- const runner = new SubProcess(process.execPath, [scriptPath, ...extraArgs], {
1075
- cwd: moduleRoot,
1076
- });
1077
-
1078
- const output = new RingBuffer(50);
1079
-
1080
- runner.on('stream-line', (line) => {
1081
- output.enqueue(line);
1082
- this.log.log(line);
1083
- });
1084
-
1085
- await runner.start(0);
1086
-
1087
- try {
1088
- await runner.join();
1089
- this.log.ok(`${scriptName} successfully ran`.green);
1090
- return {output: output.getBuff()};
1091
- } catch (err) {
1092
- const message = `Encountered an error when running '${scriptName}': ${err.message}`;
1093
- throw this._createFatalError(message);
1092
+ /**
1093
+ * Get repository URL from installed extension's package.json
1094
+ *
1095
+ */
1096
+ private async _getRepositoryUrlFromInstalled(data: ExtensionListData): Promise<string | null> {
1097
+ try {
1098
+ const pkgJsonPath = path.join(String(data.installPath), 'package.json');
1099
+ if (await fs.exists(pkgJsonPath)) {
1100
+ const pkg = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8'));
1101
+ if (pkg.repository) {
1102
+ if (typeof pkg.repository === 'string') {
1103
+ return pkg.repository;
1104
+ }
1105
+ if (pkg.repository.url) {
1106
+ return pkg.repository.url.replace(/^git\+/, '').replace(/\.git$/, '');
1107
+ }
1108
+ }
1094
1109
  }
1110
+ } catch {
1111
+ // Ignore errors reading package.json
1095
1112
  }
1113
+ return null;
1114
+ }
1096
1115
 
1116
+ /**
1117
+ * Get repository URL from npm for a package name
1118
+ *
1119
+ */
1120
+ private async _getRepositoryUrlFromNpm(pkgName: string): Promise<string | null> {
1097
1121
  try {
1098
- await new B((resolve, reject) => {
1099
- this._runUnbuffered(moduleRoot, scriptPath, extraArgs)
1100
- .once('error', (err) => {
1101
- // generally this is of the "I can't find the script" variety.
1102
- // this is a developer bug: the extension is pointing to a script that is not where the
1103
- // developer said it would be (in `appium.scripts` of the extension's `package.json`)
1104
- reject(err);
1105
- })
1106
- .once('close', (code) => {
1107
- if (code === 0) {
1108
- resolve();
1109
- } else {
1110
- reject(new Error(`Script exited with code ${code}`));
1111
- }
1112
- });
1113
- });
1114
- this.log.ok(`${scriptName} successfully ran`.green);
1115
- return {};
1116
- } catch (err) {
1117
- const message = `Encountered an error when running '${scriptName}': ${err.message}`;
1118
- throw this._createFatalError(message);
1122
+ const repoInfo = await npm.getPackageInfo(pkgName, ['repository']);
1123
+ // When requesting only 'repository', npm.getPackageInfo returns the repository object directly
1124
+ if (repoInfo) {
1125
+ if (typeof repoInfo === 'string') {
1126
+ return repoInfo;
1127
+ }
1128
+ if (repoInfo.url) {
1129
+ return repoInfo.url.replace(/^git\+/, '').replace(/\.git$/, '');
1130
+ }
1131
+ }
1132
+ } catch {
1133
+ // Ignore errors fetching from npm
1134
+ }
1135
+ return null;
1136
+ }
1137
+
1138
+ /**
1139
+ * Checks whether the given extension is compatible with the currently installed server
1140
+ *
1141
+ */
1142
+ private async _checkInstallCompatibility({
1143
+ installSpec,
1144
+ pkgName,
1145
+ pkgVer,
1146
+ installType,
1147
+ }: InstallViaNpmArgs): Promise<void> {
1148
+ if (INSTALL_TYPE_NPM !== installType) {
1149
+ return;
1119
1150
  }
1151
+
1152
+ await spinWith(this.isJsonOutput, `Checking if '${pkgName}' is compatible`, async () => {
1153
+ const [serverVersion, extVersionRequirement] = await getRemoteExtensionVersionReq(pkgName, pkgVer);
1154
+ if (serverVersion && extVersionRequirement && !semver.satisfies(serverVersion, extVersionRequirement)) {
1155
+ throw this._createFatalError(
1156
+ `'${installSpec}' cannot be installed because the server version it requires (${extVersionRequirement}) ` +
1157
+ `does not meet the currently installed one (${serverVersion}). Please install ` +
1158
+ `a compatible server version first.`
1159
+ );
1160
+ }
1161
+ });
1120
1162
  }
1163
+
1164
+ /**
1165
+ * For any `package.json` fields which a particular type of extension requires, validate the
1166
+ * presence and form of those fields on the `package.json` data, throwing an error if anything is
1167
+ * amiss.
1168
+ *
1169
+ * @param extMetadata - the data in the "appium" field of `package.json` for an extension
1170
+ * @param installSpec - Extension name/spec
1171
+ */
1172
+ protected abstract validateExtensionFields(
1173
+ extMetadata: ExtMetadata<ExtType>,
1174
+ installSpec: string
1175
+ ): void;
1176
+
1177
+ /**
1178
+ * Get the text which should be displayed to the user after an extension has been installed. This
1179
+ * is designed to be overridden by drivers/plugins with their own particular text.
1180
+ *
1181
+ */
1182
+ protected abstract getPostInstallText(args: ExtensionArgs<ExtType>): PostInstallText;
1121
1183
  }
1122
1184
 
1123
1185
  /**
1124
1186
  * This is needed to ensure proper module resolution for installed extensions,
1125
1187
  * especially ESM ones.
1126
1188
  *
1127
- * @param {ExtensionConfig<ExtensionType>} driverConfig
1128
- * @param {ExtensionConfig<ExtensionType>} pluginConfig
1129
- * @param {import('@appium/types').AppiumLogger} logger
1189
+ * @param driverConfig - active driver extension config
1190
+ * @param pluginConfig - active plugin extension config
1191
+ * @param logger - logger instance used for non-fatal symlink errors
1192
+ * @returns resolves when symlink injection has completed for all extensions
1130
1193
  */
1131
- export async function injectAppiumSymlinks(driverConfig, pluginConfig, logger) {
1132
- const installPaths = _.compact([
1194
+ export async function injectAppiumSymlinks(
1195
+ driverConfig: ExtensionConfig<any>,
1196
+ pluginConfig: ExtensionConfig<any>,
1197
+ logger: AppiumLogger
1198
+ ): Promise<void> {
1199
+ const isNpmInstalledExtension = (
1200
+ details: InstalledExtensionLike
1201
+ ): details is InstalledExtensionLike & {installType: typeof INSTALL_TYPE_NPM; installPath: string} =>
1202
+ details.installType === INSTALL_TYPE_NPM && Boolean(details.installPath);
1203
+
1204
+ const installedExtensions = [
1133
1205
  ...Object.values(driverConfig.installedExtensions || {}),
1134
- ...Object.values(pluginConfig.installedExtensions || {})
1135
- ].filter((details) => details.installType === INSTALL_TYPE_NPM)
1136
- .map((details) => details.installPath));
1206
+ ...Object.values(pluginConfig.installedExtensions || {}),
1207
+ ] as InstalledExtensionLike[];
1208
+
1209
+ const installPaths = _.compact(installedExtensions
1210
+ .filter((details): details is InstalledExtensionLike => Boolean(details))
1211
+ .filter(isNpmInstalledExtension)
1212
+ .map((details) => details.installPath));
1137
1213
  // After the extension is installed, we try to inject the appium module symlink
1138
1214
  // into the extension's node_modules folder if it is not there yet.
1139
1215
  // We also inject the symlink into other installed extensions' node_modules folders
@@ -1144,16 +1220,42 @@ export async function injectAppiumSymlinks(driverConfig, pluginConfig, logger) {
1144
1220
  );
1145
1221
  }
1146
1222
 
1223
+ /**
1224
+ * Omits `driverName`/`pluginName` props from the receipt to make a {@linkcode ExtManifest}
1225
+ */
1226
+ function receiptToManifest<ExtType extends ExtensionType>(
1227
+ receipt: ExtInstallReceipt<ExtType>
1228
+ ): ExtManifest<ExtType> {
1229
+ return _.omit(receipt, 'driverName', 'pluginName') as ExtManifest<ExtType>;
1230
+ }
1231
+
1232
+ /**
1233
+ * Fetches the remote extension version requirements
1234
+ *
1235
+ * @param pkgName Extension name
1236
+ * @param [pkgVer] Extension version (if not provided then the latest is assumed)
1237
+ */
1238
+ async function getRemoteExtensionVersionReq(
1239
+ pkgName: string,
1240
+ pkgVer?: string
1241
+ ): Promise<[string, string | null]> {
1242
+ const allDeps = await npm.getPackageInfo(
1243
+ `${pkgName}${pkgVer ? `@${pkgVer}` : ``}`,
1244
+ ['peerDependencies', 'dependencies']
1245
+ );
1246
+ const requiredVersionPair = _.flatMap(_.values(allDeps).map(_.toPairs))
1247
+ .find(([name]) => name === 'appium');
1248
+ return [npmPackage.version, requiredVersionPair ? requiredVersionPair[1] : null];
1249
+ }
1250
+
1147
1251
  /**
1148
1252
  * This is needed to ensure proper module resolution for installed extensions,
1149
1253
  * especially ESM ones.
1150
1254
  *
1151
- * @param {string} dstFolder The destination folder where the symlink should be created
1152
- * @param {import('@appium/types').AppiumLogger} logger
1153
- * @returns {Promise<void>}
1255
+ * @param dstFolder The destination folder where the symlink should be created
1154
1256
  */
1155
- async function injectAppiumSymlink(dstFolder, logger) {
1156
- let appiumModuleRoot;
1257
+ async function injectAppiumSymlink(dstFolder: string, logger: AppiumLogger): Promise<void> {
1258
+ let appiumModuleRoot = '';
1157
1259
  try {
1158
1260
  appiumModuleRoot = getAppiumModuleRoot();
1159
1261
  const symlinkPath = path.join(dstFolder, path.basename(appiumModuleRoot));
@@ -1172,184 +1274,3 @@ async function injectAppiumSymlink(dstFolder, logger) {
1172
1274
  export default ExtensionCliCommand;
1173
1275
  export {ExtensionCliCommand as ExtensionCommand};
1174
1276
 
1175
- /**
1176
- * Options for the {@linkcode ExtensionCliCommand} constructor
1177
- * @template {ExtensionType} ExtType
1178
- * @typedef ExtensionCommandOptions
1179
- * @property {ExtensionConfig<ExtType>} config - the `DriverConfig` or `PluginConfig` instance used for this command
1180
- * @property {boolean} json - whether the output of this command should be JSON or text
1181
- */
1182
-
1183
- /**
1184
- * Extra stuff about extensions; used indirectly by {@linkcode ExtensionCliCommand.list}.
1185
- *
1186
- * @typedef ExtensionListMetadata
1187
- * @property {boolean} installed - If `true`, the extension is installed
1188
- * @property {boolean} upToDate - If the extension is installed and the latest
1189
- * @property {string|null} updateVersion - If the extension is installed, the version it can be updated to
1190
- * @property {string|null} unsafeUpdateVersion - Same as above, but a major version bump
1191
- * @property {string} [updateError] - Update check error message (if present)
1192
- * @property {boolean} [devMode] - If Appium is run from an extension's working copy
1193
- * @property {string} [repositoryUrl] - Repository URL for the extension (if available)
1194
- */
1195
-
1196
- /**
1197
- * @typedef {import('@appium/types').ExtensionType} ExtensionType
1198
- * @typedef {import('@appium/types').DriverType} DriverType
1199
- * @typedef {import('@appium/types').PluginType} PluginType
1200
- */
1201
-
1202
- /**
1203
- * @template {ExtensionType} ExtType
1204
- * @typedef {import('appium/types').ExtRecord<ExtType>} ExtRecord
1205
- */
1206
-
1207
- /**
1208
- * @template {ExtensionType} ExtType
1209
- * @typedef {import('../extension/extension-config').ExtensionConfig<ExtType>} ExtensionConfig
1210
- */
1211
-
1212
- /**
1213
- * @template {ExtensionType} ExtType
1214
- * @typedef {import('appium/types').ExtMetadata<ExtType>} ExtMetadata
1215
- */
1216
-
1217
- /**
1218
- * @template {ExtensionType} ExtType
1219
- * @typedef {import('appium/types').ExtManifest<ExtType>} ExtManifest
1220
- */
1221
-
1222
- /**
1223
- * @template {ExtensionType} ExtType
1224
- * @typedef {import('appium/types').ExtPackageJson<ExtType>} ExtPackageJson
1225
- */
1226
-
1227
- /**
1228
- * @template {ExtensionType} ExtType
1229
- * @typedef {import('appium/types').ExtInstallReceipt<ExtType>} ExtInstallReceipt
1230
- */
1231
-
1232
- /**
1233
- * Possible return value for {@linkcode ExtensionCliCommand.list}
1234
- * @template {ExtensionType} ExtType
1235
- * @typedef {Partial<ExtManifest<ExtType>> & Partial<ExtensionListMetadata>} ExtensionListData
1236
- */
1237
-
1238
- /**
1239
- * @template {ExtensionType} ExtType
1240
- * @typedef {ExtManifest<ExtType> & ExtensionListMetadata} InstalledExtensionListData
1241
- */
1242
-
1243
- /**
1244
- * Return value of {@linkcode ExtensionCliCommand.list}.
1245
- * @template {ExtensionType} ExtType
1246
- * @typedef {Record<string,ExtensionListData<ExtType>>} ExtensionList
1247
- */
1248
-
1249
- /**
1250
- * Options for {@linkcode ExtensionCliCommand._run}.
1251
- * @typedef RunOptions
1252
- * @property {string} installSpec - name of the extension to run a script from
1253
- * @property {string} [scriptName] - name of the script to run. If not provided
1254
- * then all available script names will be printed
1255
- * @property {string[]} [extraArgs] - arguments to pass to the script
1256
- * @property {boolean} [bufferOutput] - if true, will buffer the output of the script and return it
1257
- */
1258
-
1259
- /**
1260
- * Options for {@linkcode ExtensionCliCommand.doctor}.
1261
- * @typedef DoctorOptions
1262
- * @property {string} installSpec - name of the extension to run doctor checks for
1263
- */
1264
-
1265
- /**
1266
- * Return value of {@linkcode ExtensionCliCommand._run}
1267
- *
1268
- * @typedef RunOutput
1269
- * @property {string[]} [output] - script output if `bufferOutput` was `true` in {@linkcode RunOptions}
1270
- */
1271
-
1272
- /**
1273
- * Options for {@linkcode ExtensionCliCommand._update}.
1274
- * @typedef ExtensionUpdateOpts
1275
- * @property {string} installSpec - the name of the extension to update
1276
- * @property {boolean} unsafe - if true, will perform unsafe updates past major revision boundaries
1277
- */
1278
-
1279
- /**
1280
- * Return value of {@linkcode ExtensionCliCommand._update}.
1281
- * @typedef ExtensionUpdateResult
1282
- * @property {Record<string,Error>} errors - map of ext names to error objects
1283
- * @property {Record<string,UpdateReport>} updates - map of ext names to {@linkcode UpdateReport}s
1284
- */
1285
-
1286
- /**
1287
- * Part of result of {@linkcode ExtensionCliCommand._update}.
1288
- * @typedef UpdateReport
1289
- * @property {string} from - version the extension was updated from
1290
- * @property {string} to - version the extension was updated to
1291
- */
1292
-
1293
- /**
1294
- * Options for {@linkcode ExtensionCliCommand._uninstall}.
1295
- * @typedef UninstallOpts
1296
- * @property {string} installSpec - the name or spec of an extension to uninstall
1297
- */
1298
-
1299
- /**
1300
- * Used by {@linkcode ExtensionCliCommand.getPostInstallText}
1301
- * @typedef ExtensionArgs
1302
- * @property {string} extName - the name of an extension
1303
- * @property {object} extData - the data for an installed extension
1304
- */
1305
-
1306
- /**
1307
- * Options for {@linkcode ExtensionCliCommand.installViaNpm}
1308
- * @typedef InstallViaNpmArgs
1309
- * @property {string} installSpec - the name or spec of an extension to install
1310
- * @property {string} pkgName - the NPM package name of the extension
1311
- * @property {import('appium/types').InstallType} installType - type of install
1312
- * @property {string} [pkgVer] - the specific version of the NPM package
1313
- */
1314
-
1315
- /**
1316
- * Object returned by {@linkcode ExtensionCliCommand.checkForExtensionUpdate}
1317
- * @typedef PossibleUpdates
1318
- * @property {string} current - current version
1319
- * @property {string?} safeUpdate - version we can safely update to if it exists, or null
1320
- * @property {string?} unsafeUpdate - version we can unsafely update to if it exists, or null
1321
- */
1322
-
1323
- /**
1324
- * Options for {@linkcode ExtensionCliCommand._install}
1325
- * @typedef InstallOpts
1326
- * @property {string} installSpec - the name or spec of an extension to install
1327
- * @property {InstallType} installType - how to install this extension. One of the INSTALL_TYPES
1328
- * @property {string} [packageName] - for git/github installs, the extension node package name
1329
- */
1330
-
1331
- /**
1332
- * @template {ExtensionType} ExtType
1333
- * @typedef {ExtType extends DriverType ? typeof import('../constants').KNOWN_DRIVERS : ExtType extends PluginType ? typeof import('../constants').KNOWN_PLUGINS : never} KnownExtensions
1334
- */
1335
-
1336
- /**
1337
- * @typedef ListOptions
1338
- * @property {boolean} showInstalled - whether should show only installed extensions
1339
- * @property {boolean} showUpdates - whether should show available updates
1340
- * @property {boolean} [verbose] - whether to show additional data from the extension
1341
- */
1342
-
1343
- /**
1344
- * Opts for {@linkcode ExtensionCliCommand.getInstallationReceipt}
1345
- * @template {ExtensionType} ExtType
1346
- * @typedef GetInstallationReceiptOpts
1347
- * @property {string} installPath
1348
- * @property {string} installSpec
1349
- * @property {ExtPackageJson<ExtType>} pkg
1350
- * @property {InstallType} installType
1351
- */
1352
-
1353
- /**
1354
- * @typedef {import('appium/types').InstallType} InstallType
1355
- */