@xpack/xpm-lib 3.0.0

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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +223 -0
  3. package/dist/index.d.ts +16 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +30 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/lib/chmod-recursive.d.ts +7 -0
  8. package/dist/lib/chmod-recursive.d.ts.map +1 -0
  9. package/dist/lib/chmod-recursive.js +81 -0
  10. package/dist/lib/chmod-recursive.js.map +1 -0
  11. package/dist/lib/errors.d.ts +11 -0
  12. package/dist/lib/errors.d.ts.map +1 -0
  13. package/dist/lib/errors.js +26 -0
  14. package/dist/lib/errors.js.map +1 -0
  15. package/dist/lib/functions/chmod-recursive.d.ts +7 -0
  16. package/dist/lib/functions/chmod-recursive.d.ts.map +1 -0
  17. package/dist/lib/functions/chmod-recursive.js +81 -0
  18. package/dist/lib/functions/chmod-recursive.js.map +1 -0
  19. package/dist/lib/functions/perform-substitutions.d.ts +20 -0
  20. package/dist/lib/functions/perform-substitutions.d.ts.map +1 -0
  21. package/dist/lib/functions/perform-substitutions.js +85 -0
  22. package/dist/lib/functions/perform-substitutions.js.map +1 -0
  23. package/dist/lib/functions/utils.d.ts +30 -0
  24. package/dist/lib/functions/utils.d.ts.map +1 -0
  25. package/dist/lib/functions/utils.js +70 -0
  26. package/dist/lib/functions/utils.js.map +1 -0
  27. package/dist/lib/init-template-base.d.ts +46 -0
  28. package/dist/lib/init-template-base.d.ts.map +1 -0
  29. package/dist/lib/init-template-base.js +275 -0
  30. package/dist/lib/init-template-base.js.map +1 -0
  31. package/dist/lib/liquid-actions.d.ts +32 -0
  32. package/dist/lib/liquid-actions.d.ts.map +1 -0
  33. package/dist/lib/liquid-actions.js +113 -0
  34. package/dist/lib/liquid-actions.js.map +1 -0
  35. package/dist/lib/liquid-build-configurations.d.ts +49 -0
  36. package/dist/lib/liquid-build-configurations.d.ts.map +1 -0
  37. package/dist/lib/liquid-build-configurations.js +267 -0
  38. package/dist/lib/liquid-build-configurations.js.map +1 -0
  39. package/dist/lib/liquid-drop.d.ts +13 -0
  40. package/dist/lib/liquid-drop.d.ts.map +1 -0
  41. package/dist/lib/liquid-drop.js +56 -0
  42. package/dist/lib/liquid-drop.js.map +1 -0
  43. package/dist/lib/liquid-engine.d.ts +5 -0
  44. package/dist/lib/liquid-engine.d.ts.map +1 -0
  45. package/dist/lib/liquid-engine.js +85 -0
  46. package/dist/lib/liquid-engine.js.map +1 -0
  47. package/dist/lib/liquid-package.d.ts +17 -0
  48. package/dist/lib/liquid-package.d.ts.map +1 -0
  49. package/dist/lib/liquid-package.js +70 -0
  50. package/dist/lib/liquid-package.js.map +1 -0
  51. package/dist/lib/package.d.ts +66 -0
  52. package/dist/lib/package.d.ts.map +1 -0
  53. package/dist/lib/package.js +700 -0
  54. package/dist/lib/package.js.map +1 -0
  55. package/dist/lib/perform-substitutions.d.ts +20 -0
  56. package/dist/lib/perform-substitutions.d.ts.map +1 -0
  57. package/dist/lib/perform-substitutions.js +85 -0
  58. package/dist/lib/perform-substitutions.js.map +1 -0
  59. package/dist/lib/policies.d.ts +13 -0
  60. package/dist/lib/policies.d.ts.map +1 -0
  61. package/dist/lib/policies.js +31 -0
  62. package/dist/lib/policies.js.map +1 -0
  63. package/dist/lib/substitutions-variables.d.ts +117 -0
  64. package/dist/lib/substitutions-variables.d.ts.map +1 -0
  65. package/dist/lib/substitutions-variables.js +51 -0
  66. package/dist/lib/substitutions-variables.js.map +1 -0
  67. package/dist/lib/types.d.ts +70 -0
  68. package/dist/lib/types.d.ts.map +1 -0
  69. package/dist/lib/types.js +13 -0
  70. package/dist/lib/types.js.map +1 -0
  71. package/dist/lib/utils.d.ts +30 -0
  72. package/dist/lib/utils.d.ts.map +1 -0
  73. package/dist/lib/utils.js +70 -0
  74. package/dist/lib/utils.js.map +1 -0
  75. package/dist/tsconfig.tsbuildinfo +1 -0
  76. package/package.json +102 -0
  77. package/src/README.md +10 -0
  78. package/src/index.ts +35 -0
  79. package/src/lib/errors.ts +29 -0
  80. package/src/lib/functions/chmod-recursive.ts +103 -0
  81. package/src/lib/functions/perform-substitutions.ts +116 -0
  82. package/src/lib/functions/utils.ts +88 -0
  83. package/src/lib/init-template-base.ts +401 -0
  84. package/src/lib/liquid-actions.ts +179 -0
  85. package/src/lib/liquid-build-configurations.ts +410 -0
  86. package/src/lib/liquid-drop.ts +99 -0
  87. package/src/lib/liquid-engine.ts +135 -0
  88. package/src/lib/liquid-package.ts +108 -0
  89. package/src/lib/package.ts +946 -0
  90. package/src/lib/policies.ts +49 -0
  91. package/src/lib/substitutions-variables.ts +177 -0
  92. package/src/lib/types.ts +109 -0
  93. package/src/package.json +3 -0
  94. package/src/tsconfig.json +10 -0
@@ -0,0 +1,946 @@
1
+ /*
2
+ * This file is part of the xPack project (http://xpack.github.io).
3
+ * Copyright (c) 2021-2026 Liviu Ionescu. All rights reserved.
4
+ *
5
+ * Permission to use, copy, modify, and/or distribute this software
6
+ * for any purpose is hereby granted, under the terms of the MIT license.
7
+ *
8
+ * If a copy of the license was not distributed with this file, it can
9
+ * be obtained from https://opensource.org/license/mit.
10
+ */
11
+
12
+ /* eslint max-len: [ "error", 80, { "ignoreUrls": true } ] */
13
+
14
+ // ----------------------------------------------------------------------------
15
+
16
+ import assert from 'node:assert'
17
+ import * as fs from 'node:fs/promises'
18
+ import * as os from 'node:os'
19
+ import * as path from 'node:path'
20
+ import util from 'node:util'
21
+ import stream from 'node:stream'
22
+
23
+ // https://www.npmjs.com/package/@npmcli/arborist
24
+ import { Arborist } from '@npmcli/arborist'
25
+
26
+ // https://www.npmjs.com/package/pacote
27
+ import pacote, { AbbreviatedManifest, ManifestResult } from 'pacote'
28
+
29
+ // https://www.npmjs.com/package/cacache
30
+ import cacache, { put } from 'cacache'
31
+
32
+ // https://www.npmjs.com/package/decompress
33
+ import decompress from 'decompress'
34
+
35
+ // https://www.npmjs.com/package/semver
36
+ import semver from 'semver'
37
+
38
+ // https://www.npmjs.com/package/del
39
+ import { deleteAsync } from 'del'
40
+
41
+ // https://www.npmjs.com/package/proxy-from-env
42
+ import { getProxyForUrl } from 'proxy-from-env'
43
+
44
+ // https://www.npmjs.com/package/https-proxy-agent
45
+ import { HttpsProxyAgent } from 'https-proxy-agent'
46
+
47
+ // https://www.npmjs.com/package/node-fetch
48
+ import fetch, { Response } from 'node-fetch'
49
+
50
+ // https://www.npmjs.com/package/@xpack/logger
51
+ import { Logger } from '@xpack/logger'
52
+
53
+ // ----------------------------------------------------------------------------
54
+
55
+ import {
56
+ JsonBuildConfiguration,
57
+ // JsonNpmPackage,
58
+ JsonXpmPackage,
59
+ XpmConfig,
60
+ } from './types.js'
61
+ import { chmodRecursive } from './functions/chmod-recursive.js'
62
+ import { XpmPolicies } from './policies.js'
63
+ import { XpmError, XpmInputError, XpmPrerequisitesError } from './errors.js'
64
+
65
+ // ----------------------------------------------------------------------------
66
+
67
+ export interface XpmPackageSpecifier {
68
+ scope?: string
69
+ name?: string
70
+ version?: string
71
+ }
72
+
73
+ export class XpmPackage {
74
+ // --------------------------------------------------------------------------
75
+ // Members.
76
+
77
+ packageFolderPath: string
78
+ jsonPackage?: JsonXpmPackage
79
+
80
+ readonly #log: Logger
81
+
82
+ // --------------------------------------------------------------------------
83
+ // Constructor.
84
+
85
+ constructor({
86
+ log,
87
+ packageFolderPath,
88
+ }: {
89
+ log: Logger
90
+ packageFolderPath: string
91
+ }) {
92
+ this.#log = log
93
+ this.packageFolderPath = packageFolderPath
94
+
95
+ log.trace(`${XpmPackage.name}(${packageFolderPath})`)
96
+ }
97
+
98
+ // --------------------------------------------------------------------------
99
+ // Methods.
100
+
101
+ async readPackageDotJson({
102
+ withThrow = false,
103
+ }: {
104
+ withThrow?: boolean
105
+ } = {}): Promise<JsonXpmPackage | undefined> {
106
+ const jsonFilePath = path.join(this.packageFolderPath, 'package.json')
107
+
108
+ let fileContent: string | Buffer
109
+ try {
110
+ fileContent = await fs.readFile(jsonFilePath)
111
+ } catch (err) {
112
+ if (withThrow) {
113
+ if (err instanceof Error) {
114
+ this.#log.trace(err.message)
115
+ }
116
+ throw new XpmInputError(
117
+ `no package.json in folder ‘${this.packageFolderPath}’`
118
+ )
119
+ } else {
120
+ return undefined
121
+ }
122
+ }
123
+
124
+ try {
125
+ this.jsonPackage = JSON.parse(fileContent.toString()) as JsonXpmPackage
126
+ } catch (err) {
127
+ if (withThrow) {
128
+ this.jsonPackage = undefined
129
+ if (err instanceof Error) {
130
+ this.#log.trace(err.message)
131
+ }
132
+ throw new XpmInputError(
133
+ `invalid package.json in folder ‘${this.packageFolderPath}’`
134
+ )
135
+ } else {
136
+ return undefined
137
+ }
138
+ }
139
+ return this.jsonPackage
140
+ }
141
+
142
+ // Note: the json is explicitly passed.
143
+ async rewritePackageDotJson(jsonPackage: JsonXpmPackage): Promise<void> {
144
+ const log = this.#log
145
+
146
+ assert(jsonPackage)
147
+ const jsonString = JSON.stringify(jsonPackage, null, 2) + '\n'
148
+
149
+ const jsonFilePath = path.join(this.packageFolderPath, 'package.json')
150
+ log.trace(`write filePath: '${jsonFilePath}'`)
151
+ await fs.writeFile(jsonFilePath, jsonString)
152
+ }
153
+
154
+ isNpmPackage(): boolean {
155
+ const jsonPackage = this.jsonPackage
156
+ if (jsonPackage?.name === undefined || jsonPackage.version === undefined) {
157
+ return false
158
+ }
159
+ const name = jsonPackage.name.trim()
160
+ if (name.length === 0) {
161
+ return false
162
+ }
163
+ const version = jsonPackage.version.trim()
164
+ if (version.length === 0) {
165
+ return false
166
+ }
167
+ return true
168
+ }
169
+
170
+ isXpmPackage(): boolean {
171
+ const jsonPackage = this.jsonPackage
172
+ if (!this.isNpmPackage()) {
173
+ return false
174
+ }
175
+ if (jsonPackage?.xpack === undefined) {
176
+ return false
177
+ }
178
+ return true
179
+ }
180
+
181
+ // Binary packages must have both executables and binaries, but
182
+ // the presence of one implies the other, so validate.
183
+ isBinaryXpmPackage() {
184
+ const jsonPackage = this.jsonPackage
185
+ if (!this.isXpmPackage()) {
186
+ return false
187
+ }
188
+ // Since Nov. 2024, `executables` is preferred to `bin`.
189
+ if (jsonPackage?.xpack.executables ?? jsonPackage?.xpack.bin) {
190
+ if (!jsonPackage.xpack.binaries) {
191
+ throw new XpmInputError(
192
+ "doesn't look like a proper binary xpm package, " +
193
+ 'package.json has no "xpack.binaries"'
194
+ )
195
+ }
196
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
197
+ if (!jsonPackage.xpack.binaries.platforms) {
198
+ throw new XpmInputError(
199
+ "doesn't look like a proper binary xpm package, " +
200
+ 'package.json has no "xpack.binaries.platforms"'
201
+ )
202
+ }
203
+ return true
204
+ }
205
+ if (jsonPackage?.xpack.binaries) {
206
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
207
+ if (!jsonPackage.xpack.binaries.platforms) {
208
+ throw new XpmInputError(
209
+ "doesn't look like a proper binary xpm package, " +
210
+ 'package.json has no "xpack.binaries.platforms"'
211
+ )
212
+ }
213
+ if (!(jsonPackage.xpack.executables ?? jsonPackage.xpack.bin)) {
214
+ throw new XpmInputError(
215
+ "doesn't look like a proper binary xpm package, " +
216
+ 'package.json has no "xpack.executables"'
217
+ )
218
+ }
219
+ return true
220
+ }
221
+ return false
222
+ }
223
+
224
+ isNodeModule() {
225
+ const jsonPackage = this.jsonPackage
226
+ return !!jsonPackage && !jsonPackage.xpack
227
+ }
228
+
229
+ isBinaryNodeModule() {
230
+ const jsonPackage = this.jsonPackage
231
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
232
+ return this.isNodeModule() && !!jsonPackage?.bin
233
+ }
234
+
235
+ hasNpmScripts(): boolean {
236
+ const jsonPackage = this.jsonPackage
237
+ if (
238
+ jsonPackage?.scripts !== undefined &&
239
+ Object.keys(jsonPackage.scripts).length > 0
240
+ ) {
241
+ return true
242
+ }
243
+
244
+ return false
245
+ }
246
+
247
+ hasXpmActions(): boolean {
248
+ const json = this.jsonPackage
249
+ if (!this.isXpmPackage()) {
250
+ return false
251
+ }
252
+ try {
253
+ if (
254
+ json?.xpack.actions !== undefined &&
255
+ Object.keys(json.xpack.actions).length > 0
256
+ ) {
257
+ return true
258
+ }
259
+ if (
260
+ json?.xpack.buildConfigurations !== undefined &&
261
+ Object.keys(json.xpack.buildConfigurations).length > 0
262
+ ) {
263
+ // Don't use a lambda, to return directly from the loop.
264
+ for (const name of Object.keys(json.xpack.buildConfigurations)) {
265
+ const buildConfiguration: JsonBuildConfiguration =
266
+ json.xpack.buildConfigurations[name]
267
+ if (
268
+ buildConfiguration.actions !== undefined &&
269
+ Object.keys(buildConfiguration.actions).length > 0
270
+ ) {
271
+ return true
272
+ }
273
+ }
274
+ }
275
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
276
+ } catch (err) {
277
+ // In case xpack is not an option to get its properties.
278
+ }
279
+
280
+ return false
281
+ }
282
+
283
+ getMinimumXpmRequired(): string | undefined {
284
+ const log = this.#log
285
+ const jsonPackage = this.jsonPackage
286
+
287
+ log.trace(`${XpmPackage.name}.getMinimumXpmRequired()`)
288
+
289
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
290
+ const version = jsonPackage?.xpack?.minimumXpmRequired
291
+ if (version === undefined) {
292
+ return undefined
293
+ }
294
+ // Remove the pre-release part.
295
+ return version.replace(/-.*$/, '')
296
+ }
297
+
298
+ async checkMinimumXpmRequired({
299
+ xpmRootFolderPath,
300
+ }: {
301
+ xpmRootFolderPath: string
302
+ }): Promise<string | undefined> {
303
+ const log = this.#log
304
+ const jsonPackage = this.jsonPackage
305
+
306
+ log.trace(`${XpmPackage.name}.checkMinimumXpmRequired()`)
307
+
308
+ if (!jsonPackage) {
309
+ // Not in a package.
310
+ return undefined
311
+ }
312
+
313
+ if (!this.isXpmPackage() || !jsonPackage.xpack.minimumXpmRequired) {
314
+ log.trace('minimumXpmRequired not used, no checks')
315
+ return undefined
316
+ }
317
+ // Remove the pre-release part.
318
+ const cleanedVersion = semver.clean(
319
+ jsonPackage.xpack.minimumXpmRequired.replace(/-.*$/, '')
320
+ )
321
+ if (!cleanedVersion) {
322
+ return undefined
323
+ }
324
+ const minimumXpmRequired: string = cleanedVersion
325
+
326
+ log.trace(`minimumXpmRequired: ${minimumXpmRequired}`)
327
+
328
+ let jsonXpmCliPackage: JsonXpmPackage | undefined
329
+ try {
330
+ const cliXpmPackage = new XpmPackage({
331
+ log,
332
+ packageFolderPath: xpmRootFolderPath,
333
+ })
334
+ jsonXpmCliPackage = await cliXpmPackage.readPackageDotJson({
335
+ withThrow: true,
336
+ })
337
+ } catch (err) {
338
+ if (err instanceof Error) {
339
+ log.trace(err.message)
340
+ } else {
341
+ log.trace(err)
342
+ }
343
+ return undefined
344
+ }
345
+ assert(jsonXpmCliPackage)
346
+ log.trace(jsonXpmCliPackage.version)
347
+
348
+ if (!jsonXpmCliPackage.version) {
349
+ return undefined
350
+ }
351
+
352
+ // Remove the pre-release part.
353
+ const xpmVersion = semver.clean(
354
+ jsonXpmCliPackage.version.replace(/-.*$/, '')
355
+ )
356
+ if (!xpmVersion) {
357
+ return undefined
358
+ }
359
+ if (semver.lt(xpmVersion, minimumXpmRequired)) {
360
+ throw new XpmPrerequisitesError(
361
+ 'package ' +
362
+ (jsonPackage.name ? `'${jsonPackage.name}' ` : '') +
363
+ `requires xpm v${minimumXpmRequired} or later, please upgrade`
364
+ )
365
+ }
366
+ // Check passed.
367
+ return minimumXpmRequired
368
+ }
369
+
370
+ parsePackageSpecifier({
371
+ npmPackageSpecifier,
372
+ }: {
373
+ npmPackageSpecifier: string
374
+ }): XpmPackageSpecifier {
375
+ assert(npmPackageSpecifier)
376
+
377
+ const log = this.#log
378
+
379
+ let scope
380
+ let name
381
+ let version
382
+
383
+ if (npmPackageSpecifier.startsWith('@')) {
384
+ const arr = npmPackageSpecifier.split('/')
385
+ if (arr.length > 2) {
386
+ throw new XpmInputError(`'${npmPackageSpecifier}' not a package name`)
387
+ }
388
+ scope = arr[0]
389
+ if (arr.length > 1) {
390
+ const arr2 = arr[1].split('@')
391
+ name = arr2[0]
392
+ if (arr2.length > 1) {
393
+ version = arr2[1]
394
+ }
395
+ }
396
+ } else {
397
+ const arr2 = npmPackageSpecifier.split('@')
398
+ name = arr2[0]
399
+ if (arr2.length > 1) {
400
+ version = arr2[1]
401
+ }
402
+ }
403
+ log.trace(
404
+ `${npmPackageSpecifier} => ` +
405
+ `${scope ?? '?'} ${name ?? '?'} ${version ?? '?'}`
406
+ )
407
+
408
+ return { scope, name, version }
409
+ }
410
+
411
+ getPlatformKey({
412
+ doForce32bit = false,
413
+ }: {
414
+ doForce32bit?: boolean
415
+ } = {}): string {
416
+ const log = this.#log
417
+
418
+ const platform = process.platform
419
+ let arch = process.arch
420
+ if (doForce32bit) {
421
+ if (platform === 'win32' && arch === 'x64') {
422
+ arch = 'ia32'
423
+ } else if (platform === 'linux' && arch === 'x64') {
424
+ arch = 'ia32'
425
+ } else if (platform === 'linux' && arch === 'arm64') {
426
+ arch = 'arm'
427
+ }
428
+ }
429
+ const key = `${platform}-${arch}`
430
+ log.trace(`platform key: ${key}`)
431
+ return key
432
+ }
433
+
434
+ async pacoteCreateManifest({
435
+ specifier,
436
+ cacheFolderPath,
437
+ }: {
438
+ specifier: string
439
+ cacheFolderPath: string
440
+ }): Promise<AbbreviatedManifest & ManifestResult> {
441
+ const log = this.#log
442
+ log.trace(`${XpmPackage.name}.pacoteCreateManifest('${specifier}')`)
443
+ const manifest = await pacote.manifest(specifier, {
444
+ cache: cacheFolderPath,
445
+ })
446
+
447
+ return manifest
448
+ }
449
+
450
+ async pacoteExtractPackage({
451
+ packFullName,
452
+ specifier,
453
+ destinationFolderPath,
454
+ cacheFolderPath,
455
+ setReadOnly,
456
+ verboseMessage,
457
+ config,
458
+ policies,
459
+ }: {
460
+ packFullName: string
461
+ specifier: string
462
+ destinationFolderPath: string
463
+ cacheFolderPath: string
464
+ setReadOnly: boolean
465
+ verboseMessage: string
466
+ config: XpmConfig
467
+ policies: XpmPolicies
468
+ }): Promise<void> {
469
+ assert(packFullName)
470
+ assert(specifier)
471
+ assert(destinationFolderPath)
472
+ assert(cacheFolderPath)
473
+ assert(verboseMessage)
474
+ assert(config)
475
+ assert(policies)
476
+
477
+ const log = this.#log
478
+ log.trace(`${XpmPackage.name}.pacoteExtractContent('${specifier}')`)
479
+
480
+ let destinationXpmPackage = new XpmPackage({
481
+ log,
482
+ packageFolderPath: destinationFolderPath,
483
+ })
484
+ const jsonDestination = await destinationXpmPackage.readPackageDotJson()
485
+ if (jsonDestination) {
486
+ // The package is already present in the destination folder.
487
+ if (!config.doForce) {
488
+ if (!config.doSkipIfInstalled) {
489
+ log.warn(
490
+ `package ${packFullName} already installed, ` +
491
+ 'use --force to overwrite'
492
+ )
493
+ }
494
+ return // Not an error, proceed to other packages.
495
+ }
496
+
497
+ if (setReadOnly) {
498
+ if (config.isDryRun) {
499
+ log.verbose('Pretend changing permissions to read-write...')
500
+ log.verbose(
501
+ 'Pretend removing existing package from ' +
502
+ `'${destinationFolderPath}'...`
503
+ )
504
+ } else {
505
+ log.verbose('Changing permissions to read-write...')
506
+ await chmodRecursive({
507
+ inputPath: destinationFolderPath,
508
+ readOnly: false,
509
+ log,
510
+ })
511
+
512
+ log.verbose(
513
+ `Removing existing package from '${destinationFolderPath}'...`
514
+ )
515
+ await deleteAsync(destinationFolderPath, { force: true })
516
+ }
517
+ }
518
+ }
519
+
520
+ const destinationTmpFolderPath = destinationFolderPath + '.tmp'
521
+ log.trace(`del(${destinationTmpFolderPath})`)
522
+ await deleteAsync(destinationTmpFolderPath, { force: true })
523
+
524
+ if (log.isVerbose && verboseMessage) {
525
+ log.verbose(verboseMessage)
526
+ }
527
+
528
+ if (config.isDryRun) {
529
+ if (!log.isVerbose) {
530
+ log.info(`${packFullName} => '${destinationFolderPath}' (dry run)`)
531
+ }
532
+ } else {
533
+ await this.pacoteExtract({
534
+ specifier: specifier,
535
+ destinationFolderPath: destinationTmpFolderPath,
536
+ cacheFolderPath,
537
+ })
538
+ if (!log.isVerbose) {
539
+ log.info(`${packFullName} => '${destinationFolderPath}'`)
540
+ }
541
+ destinationXpmPackage = new XpmPackage({
542
+ log,
543
+ packageFolderPath: destinationTmpFolderPath,
544
+ })
545
+ }
546
+
547
+ await destinationXpmPackage.readPackageDotJson()
548
+ if (!destinationXpmPackage.isXpmPackage()) {
549
+ if (!policies.shareNpmDependencies) {
550
+ log.trace(`del(${destinationTmpFolderPath})`)
551
+ await deleteAsync(destinationTmpFolderPath, { force: true })
552
+ throw new XpmInputError(
553
+ `${packFullName} is not an xpm package, use npm to install it`
554
+ )
555
+ }
556
+ log.debug(
557
+ `'${destinationFolderPath}' doesn't look like an ` +
558
+ 'xpm package, package.json has no "xpack"'
559
+ )
560
+ return
561
+ }
562
+
563
+ if (config.isDryRun) {
564
+ if (setReadOnly) {
565
+ log.verbose('Pretend changing permissions to read-only...')
566
+ }
567
+ } else {
568
+ await this.#downloadBinaries({
569
+ destinationXpmPackage,
570
+ destinationFolderPath,
571
+ cacheFolderPath,
572
+ config,
573
+ })
574
+
575
+ // When everything is ready, rename the folder to the desired name.
576
+ await fs.rename(destinationTmpFolderPath, destinationFolderPath)
577
+ log.trace(`rename(${destinationTmpFolderPath}, ${destinationFolderPath})`)
578
+
579
+ log.trace(`in '${destinationFolderPath}'`)
580
+ if (setReadOnly) {
581
+ log.verbose('Changing permissions to read-only...')
582
+ await chmodRecursive({
583
+ inputPath: destinationFolderPath,
584
+ readOnly: true,
585
+ log,
586
+ })
587
+ }
588
+ }
589
+ }
590
+
591
+ async pacoteExtract({
592
+ specifier,
593
+ destinationFolderPath,
594
+ cacheFolderPath,
595
+ }: {
596
+ specifier: string
597
+ destinationFolderPath: string
598
+ cacheFolderPath: string
599
+ }): Promise<void> {
600
+ assert(specifier)
601
+ assert(destinationFolderPath)
602
+ assert(cacheFolderPath)
603
+
604
+ const log = this.#log
605
+ log.trace(`${XpmPackage.name}.pacoteExtract(${specifier})`)
606
+
607
+ try {
608
+ log.trace(`pacote.extract(${specifier})`)
609
+ const fetchResult = await pacote.extract(
610
+ specifier,
611
+ destinationFolderPath,
612
+ { cache: cacheFolderPath, Arborist }
613
+ )
614
+ log.trace(`fetchResult: ${util.inspect(fetchResult)}`)
615
+ } catch (err) {
616
+ log.trace(util.inspect(err))
617
+ throw new XpmInputError(`Package ${specifier} not found`)
618
+ }
619
+ }
620
+
621
+ async #downloadBinaries({
622
+ destinationXpmPackage,
623
+ destinationFolderPath,
624
+ cacheFolderPath,
625
+ config,
626
+ }: {
627
+ destinationXpmPackage: XpmPackage
628
+ destinationFolderPath: string
629
+ cacheFolderPath: string
630
+ config: XpmConfig
631
+ }): Promise<void> {
632
+ assert(destinationXpmPackage)
633
+ assert(destinationFolderPath)
634
+ assert(cacheFolderPath)
635
+ assert(config)
636
+
637
+ const log = this.#log
638
+ const packageFolderPath = destinationXpmPackage.packageFolderPath
639
+ const jsonPackage = destinationXpmPackage.jsonPackage
640
+ assert(jsonPackage)
641
+
642
+ log.trace(`${XpmPackage.name}.downloadBinaries(${packageFolderPath})`)
643
+ if (!destinationXpmPackage.isXpmPackage()) {
644
+ log.debug(
645
+ "doesn't look like an xpm package, " + 'package.json has no "xpack"'
646
+ )
647
+ return
648
+ }
649
+ if (!destinationXpmPackage.isBinaryXpmPackage()) {
650
+ log.debug(
651
+ "doesn't look like an xpm package, " +
652
+ 'package.json has no "xpack.executables" and "xpack.binaries"'
653
+ )
654
+ return
655
+ }
656
+
657
+ const platformKey = this.getPlatformKey()
658
+ const platformKeyAliases = new Set<string>()
659
+
660
+ if (['linux-x32', 'linux-x86', 'linux-ia32'].includes(platformKey)) {
661
+ platformKeyAliases.add('linux-x32')
662
+ platformKeyAliases.add('linux-x86')
663
+ platformKeyAliases.add('linux-ia32') // official
664
+ } else if (['win32-x32', 'win32-x86', 'win32-ia32'].includes(platformKey)) {
665
+ platformKeyAliases.add('win32-x32')
666
+ platformKeyAliases.add('win32-x86')
667
+ platformKeyAliases.add('win32-ia32') // official
668
+ } else {
669
+ platformKeyAliases.add(platformKey)
670
+ }
671
+
672
+ assert(jsonPackage.xpack.binaries)
673
+ const platforms = jsonPackage.xpack.binaries.platforms
674
+
675
+ let platform
676
+ for (const item of platformKeyAliases) {
677
+ if (Object.prototype.hasOwnProperty.call(platforms, item)) {
678
+ platform = platforms[item]
679
+ break
680
+ }
681
+ }
682
+ if (!platform) {
683
+ throw new XpmInputError(`platform ${platformKey} not supported`)
684
+ }
685
+
686
+ if (!jsonPackage.xpack.binaries.baseUrl) {
687
+ throw new XpmInputError(
688
+ 'missing "xpack.binaries.baseUrl" in package.json'
689
+ )
690
+ }
691
+
692
+ if (platform.skip) {
693
+ log.warn('no binaries are available for this platform, command ignored')
694
+ return
695
+ }
696
+
697
+ if (!platform.fileName) {
698
+ throw new XpmInputError(
699
+ `missing xpack.binaries.platform[${platformKey}].fileName`
700
+ )
701
+ }
702
+
703
+ // Prefer the platform specific URL, if available, otherwise
704
+ // use the common URL.
705
+ let fileUrl = platform.baseUrl ?? jsonPackage.xpack.binaries.baseUrl
706
+ if (!fileUrl.endsWith('/')) {
707
+ fileUrl += '/'
708
+ }
709
+
710
+ fileUrl += platform.fileName
711
+
712
+ let hashAlgorithm = '?'
713
+ let hexSum = '?'
714
+ if (platform.sha256) {
715
+ hashAlgorithm = 'sha256'
716
+ hexSum = platform.sha256
717
+ } else if (platform.sha512) {
718
+ hashAlgorithm = 'sha512'
719
+ hexSum = platform.sha512
720
+ }
721
+
722
+ let integrityDigest = '?'
723
+ if (hexSum) {
724
+ const buff = Buffer.from(hexSum, 'hex')
725
+ integrityDigest = `${hashAlgorithm}-${buff.toString('base64')}`
726
+ }
727
+ log.trace(`expected integrity digest ${integrityDigest} for ${hexSum}`)
728
+
729
+ if (config.isDryRun) {
730
+ log.info(`Pretend downloading ${fileUrl}...`)
731
+ log.info(`Pretend extracting '${platform.fileName}'...`)
732
+ return
733
+ }
734
+
735
+ const cacheKey = `xpm:binaries:${platform.fileName}`
736
+ log.trace(`getting cacache info(${cacheFolderPath}, ${cacheKey})...`)
737
+ // Debug only, to force the downloads.
738
+ // await cacache.rm.entry(cacheFolderPath, cacheKey)
739
+ let cacheInfo = await cacache.get.info(cacheFolderPath, cacheKey)
740
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
741
+ if (!cacheInfo) {
742
+ // If the cache has no idea of the desired file, proceed with
743
+ // the download.
744
+ log.info(`Downloading ${fileUrl}...`)
745
+ const opts: { integrity?: string } = {}
746
+ if (integrityDigest) {
747
+ // Enable hash checking.
748
+ opts.integrity = integrityDigest
749
+ }
750
+ try {
751
+ await this.cacheArchive({
752
+ url: fileUrl,
753
+ cacheFolderPath,
754
+ key: cacheKey,
755
+ opts,
756
+ })
757
+ log.trace(`cache written for ${fileUrl}`)
758
+ } catch (err) {
759
+ log.trace(util.inspect(err))
760
+ // Do not throw yet, only display the error.
761
+ if (err instanceof Error) {
762
+ log.info(err.message)
763
+ } else {
764
+ log.info(String(err))
765
+ }
766
+ if (os.platform() === 'win32') {
767
+ log.info(
768
+ 'If you have an aggressive antivirus, try to ' +
769
+ 'reconfigure it, or temporarily disable it'
770
+ )
771
+ }
772
+ throw new XpmError('download failed, quit')
773
+ }
774
+ // Update the cache info after downloading the file.
775
+ cacheInfo = await cacache.get.info(cacheFolderPath, cacheKey)
776
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
777
+ if (!cacheInfo) {
778
+ throw new XpmError('download failed, quit')
779
+ }
780
+ }
781
+
782
+ log.trace(`cache path ${cacheInfo.path} for ${fileUrl}`)
783
+
784
+ // The number of initial folder levels to skip.
785
+ let skip = 0
786
+ if (jsonPackage.xpack.binaries.skip) {
787
+ try {
788
+ skip = jsonPackage.xpack.binaries.skip
789
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
790
+ } catch (err) {
791
+ // Ignore invalid skip value, use default
792
+ }
793
+ }
794
+ log.trace(`skip ${skip.toString()} levels`)
795
+
796
+ const contentFolderRelativePath =
797
+ jsonPackage.xpack.binaries.destination || '.content'
798
+ const contentFolderPath = path.join(
799
+ packageFolderPath,
800
+ contentFolderRelativePath
801
+ )
802
+ const destinationContentFolderPath = path.join(
803
+ destinationFolderPath,
804
+ contentFolderRelativePath
805
+ )
806
+
807
+ log.trace(`del ${contentFolderPath}`)
808
+ await deleteAsync(contentFolderPath, { force: true })
809
+
810
+ const cacheInfoPath = cacheInfo.path
811
+ log.trace(`cacheInfoPath ${cacheInfoPath}`)
812
+ let res: decompress.File[] = []
813
+ // Currently this includes decompressTar(), decompressTarbz2(),
814
+ // decompressTargz(), decompressUnzip().
815
+ log.info(`Extracting '${platform.fileName}'...`)
816
+
817
+ res = await decompress(cacheInfoPath, contentFolderPath, {
818
+ strip: skip,
819
+ })
820
+
821
+ if (log.isVerbose) {
822
+ // The common value is self relative ./.content; remove the folder.
823
+ const shownFolderRelativePath = contentFolderRelativePath.replace(
824
+ /^\.\//,
825
+ ''
826
+ )
827
+ assert(jsonPackage.version)
828
+ log.verbose(
829
+ `${res.length.toString()} files extracted in ` +
830
+ `'${jsonPackage.version}/${shownFolderRelativePath}'`
831
+ )
832
+ } else {
833
+ log.info(
834
+ `${res.length.toString()} files => '${destinationContentFolderPath}'`
835
+ )
836
+ }
837
+ }
838
+
839
+ // Returns nothing. Used by downloadBinaries().
840
+ async cacheArchive({
841
+ url,
842
+ cacheFolderPath,
843
+ key,
844
+ opts,
845
+ }: {
846
+ url: string
847
+ cacheFolderPath: string
848
+ key: string
849
+
850
+ opts: put.Options
851
+ }): Promise<void> {
852
+ assert(url)
853
+ assert(cacheFolderPath)
854
+ assert(key)
855
+ assert(opts)
856
+ const log = this.#log
857
+
858
+ // https://github.com/node-fetch/node-fetch/blob/main/docs/ERROR-HANDLING.md
859
+ // https://github.com/node-fetch/node-fetch/blob/main/test/main.js
860
+ // https://www.scrapingbee.com/blog/proxy-node-fetch/
861
+ // https://iproyal.com/blog/how-do-i-use-a-node-fetch-proxy/
862
+
863
+ let response: Response | undefined
864
+ let timeoutMillis = 1000
865
+ // If no proxy is set, an empty string is returned.
866
+
867
+ const proxyUrl: string = getProxyForUrl(url)
868
+ log.trace(`proxyUrl ${proxyUrl}`)
869
+ const maxRetry = 5
870
+ for (let retry = 0; retry < maxRetry; ++retry) {
871
+ try {
872
+ if (proxyUrl.length > 0) {
873
+ const proxyAgent = new HttpsProxyAgent(proxyUrl)
874
+ log.trace(`proxyAgent ${util.inspect(proxyAgent)} for ${url}`)
875
+ response = await fetch(url, { agent: proxyAgent })
876
+ } else {
877
+ response = await fetch(url)
878
+ }
879
+ } catch (err) {
880
+ log.trace(util.inspect(err))
881
+ const errorMessage = err instanceof Error ? err.message : String(err)
882
+ throw new XpmError(`${errorMessage} in fetch ${url}`)
883
+ }
884
+
885
+ log.debug(`fetch.status ${response.status.toString()} ${url}`)
886
+ log.trace(`fetch.statusText ${response.statusText} ${url}`)
887
+
888
+ if (!response.ok) {
889
+ break
890
+ }
891
+
892
+ // the HTTP response status was [200, 300).
893
+ // https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_success
894
+
895
+ const pipelinePromise = util.promisify(stream.pipeline)
896
+
897
+ log.trace(`create write stream for ${key}`)
898
+
899
+ const cacacheWriteStream = cacache.put.stream(cacheFolderPath, key, opts)
900
+ log.trace(`create pipeline for ${key}`)
901
+ try {
902
+ assert(response.body)
903
+ await pipelinePromise(response.body, cacacheWriteStream)
904
+ // If no exception, everything must be ok.
905
+ return
906
+ } catch (err) {
907
+ log.trace(util.inspect(err))
908
+ const errorMessage = err instanceof Error ? err.message : String(err)
909
+ if (retry >= maxRetry) {
910
+ throw new XpmError(`${errorMessage} in pipeline ${url}`)
911
+ }
912
+ // For now retry on all errors during download.
913
+ // TODO: identify non recoverable and quit.
914
+ log.warn(`${errorMessage} while downloading ${url}, retrying...`)
915
+ const tenPercent = timeoutMillis * 0.1
916
+ // +/- 10%
917
+ // Math.random() * (max - min) + min
918
+ const jitter = Math.floor(
919
+ Math.random() * (tenPercent - -tenPercent) + -tenPercent
920
+ )
921
+ timeoutMillis = timeoutMillis + jitter
922
+ log.debug(`timeoutMillis: ${timeoutMillis.toString()}`)
923
+ const sleep = (ms: number) =>
924
+ new Promise((resolve) => setTimeout(resolve, ms))
925
+ await sleep(timeoutMillis)
926
+
927
+ // 1 2 4 8 16... seconds
928
+ timeoutMillis = timeoutMillis * 2
929
+ }
930
+ }
931
+
932
+ // res.status < 200 || res.status >= 300 (4xx, 5xx)
933
+ // 1xx informational
934
+ // 3xx: redirection messages
935
+ // 4xx: client error
936
+ // 5xx: server error
937
+ // TODO: detect cases that can be retried.
938
+ assert(response)
939
+ throw new XpmError(
940
+ `server returned ${response.status.toString()}: ` +
941
+ `${response.statusText} for ${key}`
942
+ )
943
+ }
944
+ }
945
+
946
+ // ----------------------------------------------------------------------------