@coalescesoftware/coa 1.0.119 → 1.0.120

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.
package/src/Package.ts ADDED
@@ -0,0 +1,1018 @@
1
+ /* eslint-disable multiline-comment-style */
2
+ import * as Shared from "@coalescesoftware/shared"
3
+ import fs from "fs"
4
+
5
+ const os = require("os");
6
+
7
+ /**
8
+ * coa package uses the same config file that should be setup to leverage coa.
9
+ *
10
+ * project - the user's ws data
11
+ * package - the targetted repo being applied to the project
12
+ * dependency - any package installed to a project/another package
13
+ *
14
+ * coa add works at a high level in these steps:
15
+ * 1 - collects ws data from a targetted repo (local or remote)
16
+ * 2 - namespaces that ws data w/package ID
17
+ * 3 - adds to firestore packages collection the info for the package being installed (dependency information) w/status: 'adding'
18
+ * 4 - updates project ws data on firestore with namespaced ws data from package
19
+ * 5 - updates firestore package dependency information w/status: 'added' and a timestamp
20
+ * 6 - in case of error, will clean up any added package information including dependency info from project ws data on FS
21
+ *
22
+ * coa remove works similarly
23
+ */
24
+
25
+ export const localInstallKeyword = "file:"
26
+ const PackagesLogger = Shared.Logging.GetLogger(Shared.Logging.LoggingArea.CLI)
27
+
28
+ type TPackageIdentifier = string // the ID of the package (currently comes from project name assigned in the git settings)
29
+ type TPackageVersion = string // for now version is a URL for the git repo
30
+
31
+ type TPackageGitCommitInfo = {
32
+ commit: string
33
+ }
34
+
35
+ type TPackageFileInfo = {
36
+ filePath: TPackageVersion
37
+ }
38
+
39
+ export type TVersionInfo = TPackageGitCommitInfo | TPackageFileInfo
40
+
41
+ interface IEntityPackageInformation { // added to entity to track versioning under entity.packageInfo
42
+ id: TPackageIdentifier,
43
+ version: TPackageVersion,
44
+ }
45
+
46
+ interface IPackageInformation { // added to workspace as own entity in workspaceData.packages
47
+ id: TPackageIdentifier,
48
+ status: EPackageStatus,
49
+ addedBy: string, // fbTeam userID
50
+ createdAt: firebase.default.firestore.Timestamp | null, // should be timestamped by firstore
51
+ version: TPackageVersion,
52
+ versionInfo: TVersionInfo,
53
+ manifest?: Shared.Workspaces.AllWorkspaceData // added on success of firestore flush - all entities that have been added by the package
54
+ }
55
+
56
+ // now a collection in AllWorkspaceData
57
+ export interface IPackages {
58
+ [packageID: string]: IPackageInformation
59
+ }
60
+
61
+ enum EPackageStatus {
62
+ adding = "adding",
63
+ added = "added",
64
+ removing = "removing",
65
+ error = "error",
66
+ }
67
+
68
+ export interface IPackageContext {
69
+ firestore
70
+ teamID: string
71
+ environmentID: number
72
+ loggerContext: Shared.Logging.LogContext
73
+ timestamp // a firestore timestamp function called on firestore at time of successful install
74
+ }
75
+
76
+ type TNamespacedEntityData =
77
+ Shared.Runner.IBSSteps |
78
+ Shared.State.Workspaces |
79
+ Shared.FolderOperations.FoldersData |
80
+ Shared.JobOperations.JobsData |
81
+ Shared.MacroOperations.MacrosData |
82
+ Shared.StepTypes.AllStepTypesParsed |
83
+ Shared.MetadataOperations.ILocations
84
+
85
+ interface INamespacerDictionary {
86
+ [entity: string]: {
87
+ add: (packageID: TPackageIdentifier, packageURL: string, ...args: any) => TNamespacedEntityData
88
+ remove: (packageID: TPackageIdentifier, ...args: any) => TNamespacedEntityData
89
+ }
90
+ }
91
+
92
+ enum EVersionType {
93
+ file = "file",
94
+ url = "url"
95
+ }
96
+
97
+ ////////////////////////////////////////////////////////////////////
98
+ //////////////////// reusable namespacing helper functions
99
+ ////////////////////////////////////////////////////////////////////
100
+
101
+ /**
102
+ * Recieves a string or a number that will be namespaced (NAMESPACEVALUE::STRINGorNUMBER)
103
+ * @param value
104
+ * @param namespace
105
+ * @returns
106
+ */
107
+ const UpdateValueWithNamespace = (value: string | number, namespace: TPackageIdentifier): string => {
108
+ Shared.Common.assert(Shared.Logging.LoggingArea.Packages, typeof namespace === "string", `Namespace was not a string, but was type ${typeof namespace}`)
109
+ Shared.Common.assert(Shared.Logging.LoggingArea.Packages, !!namespace.trim(), `Namespace cannot be an empty string`)
110
+ Shared.Common.assert(Shared.Logging.LoggingArea.Packages, typeof value === "string" || typeof value === "number", `Value was not a string, but was type ${typeof value}`)
111
+ const stringValue = String(value)
112
+
113
+ Shared.Common.assert(Shared.Logging.LoggingArea.Packages, stringValue.indexOf("::") === -1, `value to namespace contained invalid character ":"`)
114
+ Shared.Common.assert(Shared.Logging.LoggingArea.Packages, namespace.indexOf("::") === -1, `namespace contained invalid character ":"`)
115
+
116
+ if (stringValue.trim() === "") return stringValue //empty string should not be namespaced but returned as is
117
+
118
+ return `${namespace}::${value}`
119
+ }
120
+
121
+ /**
122
+ * Updates originalObject field values (fieldsToUpdate) using fieldValueUpdateCB and returns original value for non selected fields
123
+ *
124
+ * @param entity object with field values to update
125
+ * @param entityFieldsToUpdate an array of fields to update, or null to update all fields
126
+ * @param namespacerCB CB to call on each field to update
127
+ * @returns entity with field values that have been namespaced or updated with CB
128
+ */
129
+ const EntityNamespacer = (
130
+ entity: any,
131
+ entityFieldsToUpdate: (string | number)[] | null,
132
+ namespacerCB: (fieldValueToUpdate) => any
133
+ ): any => {
134
+ const updatedEntity = { ...entity }
135
+
136
+ if (!entityFieldsToUpdate) {
137
+ // update all
138
+ Object.keys(entity).forEach(field => {
139
+ updatedEntity[field] = namespacerCB(updatedEntity[field])
140
+ })
141
+ } else {
142
+ Object.keys(entity).forEach(key => {
143
+ // if key included in keypath array, update, otherwise return original value
144
+ if (entityFieldsToUpdate.includes(key)) {
145
+ updatedEntity[key] = namespacerCB(updatedEntity[key])
146
+ } else {
147
+ updatedEntity[key] = entity[key]
148
+ }
149
+ })
150
+ }
151
+
152
+ return updatedEntity
153
+ }
154
+
155
+ ////////////////////////////////////////////////////////////////////
156
+ ///////////////// Parent namespacing functions
157
+ ////////////////////////////////////////////////////////////////////
158
+ /**
159
+ * internal functions that will add or remove namespaced package data
160
+ * to/from a project's ws data and return the modified workspace data
161
+ */
162
+
163
+ /**
164
+ * function adds namespaced package data to workspace data and return the modified workspace data
165
+ *
166
+ * @param allWorkspaceData
167
+ * @param packageInfo
168
+ * @param packageContext
169
+ * @returns AllWorkspaceData with added namespaced data
170
+ */
171
+ const GetNamespacedWorkspaceData = (
172
+ allWorkspaceData: Shared.Workspaces.AllWorkspaceData,
173
+ packageInfo: IPackageInformation,
174
+ packageContext: IPackageContext
175
+ ): Shared.Workspaces.AllWorkspaceData => {
176
+ const { id, version } = packageInfo
177
+ const namespacedWorkspaceData = {}
178
+
179
+ Object.keys(allWorkspaceData).forEach(wsKey => {
180
+ try {
181
+ if (WorkspaceEntityNamespaceFunctionLookup[wsKey]) {
182
+ PackagesLogger.info(`Preparing workspace data for import: ${wsKey}`)
183
+ namespacedWorkspaceData[wsKey] = WorkspaceEntityNamespaceFunctionLookup[wsKey].add(id, version, allWorkspaceData[wsKey])
184
+ }
185
+ } catch (error) {
186
+ const message = `error when preparing workspace data for import: ${wsKey}`
187
+
188
+ if (error instanceof Error) {
189
+ PackagesLogger.errorContext(packageContext.loggerContext, message, error.message, error.name, error.stack, error)
190
+ } else {
191
+ PackagesLogger.errorContext(packageContext.loggerContext, message, error)
192
+ }
193
+
194
+ throw error
195
+ }
196
+ })
197
+
198
+ return namespacedWorkspaceData as Shared.Workspaces.AllWorkspaceData
199
+ }
200
+
201
+ /**
202
+ * will remove namespaced package workspace data from allWorkspaceData and return the updated object
203
+ *
204
+ * @param allWorkspaceData
205
+ * @param packageInfo
206
+ * @param packageContext
207
+ * @returns AllWorkspaceData with added namespaced data
208
+ */
209
+ const RemoveNamespacedWorkspaceData = (
210
+ allWorkspaceData: Shared.Workspaces.AllWorkspaceData,
211
+ packageInfo: IPackageInformation,
212
+ packageContext: IPackageContext
213
+ ): Shared.Workspaces.AllWorkspaceData => {
214
+ const prunedWorkspaceData = {}
215
+ const { id } = packageInfo
216
+
217
+ Object.keys(allWorkspaceData).forEach(wsKey => {
218
+ try {
219
+ // if included in entities that should be imported, then add with namespace
220
+ if (WorkspaceEntityNamespaceFunctionLookup[wsKey]) {
221
+ PackagesLogger.info(`Pruning workspace data: ${wsKey}`)
222
+ prunedWorkspaceData[wsKey] = WorkspaceEntityNamespaceFunctionLookup[wsKey].remove(id, allWorkspaceData[wsKey])
223
+ } else {
224
+ // else just add back the original data
225
+ prunedWorkspaceData[wsKey] = allWorkspaceData[wsKey]
226
+ }
227
+ } catch (error) {
228
+ const message = `error when pruning workspace data for: ${wsKey}`
229
+
230
+ if (error instanceof Error) {
231
+ PackagesLogger.errorContext(packageContext.loggerContext, message, error.message, error.name, error.stack, error)
232
+ } else {
233
+ PackagesLogger.errorContext(packageContext.loggerContext, message, error)
234
+ }
235
+
236
+ throw error
237
+ }
238
+ })
239
+
240
+ return prunedWorkspaceData as Shared.Workspaces.AllWorkspaceData
241
+ }
242
+
243
+ /**
244
+ * Takes a parentEntityObject (ie 'stepTypes') that contains nested entity objects (ie 'stepType-1') that will be namespaced
245
+ * a) all nested entity object keys will be namespaced
246
+ * b) all nested entity object field values (fieldsToUpdate) will be namespaced
247
+ * c) (optional) additional work can be done on the newly namespaced nested entity object using the nestedObjectUpdateCB
248
+ *
249
+ * @param parentEntityObject object containing nested entity objects which will be iterated through and namespaced
250
+ * @param packageID used as new namespace
251
+ * @param fieldsToUpdate array of fields whose values will be namespaced for each nested entity object, or null to update all fields
252
+ * @param nestedObjectUpdateCB (optional) any additional work that should be done to namespaced nested entity object
253
+ * @param entity the entity (singular) being updated, ie. "step type" or "folder", etc. used for info logger
254
+ * @returns
255
+ */
256
+ const NamespaceEntity = (
257
+ parentEntityObject: any,
258
+ packageID: TPackageIdentifier,
259
+ packageVersion: TPackageVersion,
260
+ fieldsToUpdate: string[] | null,
261
+ nestedObjectUpdateCB: ((nestedObject: any, key: string) => void) | null,
262
+ entity: string
263
+ ) => {
264
+ const namespacedDataObject = {}
265
+
266
+ Object.keys(parentEntityObject).forEach(key => {
267
+ const isPackageImportedObject = !!Shared.Common.getValueSafe(parentEntityObject[key], [ "packageInfo", "id" ], null)
268
+
269
+ if (isPackageImportedObject) return // do not namespace already namespaced data
270
+
271
+ const namespacedKey = UpdateValueWithNamespace(key, packageID)
272
+ PackagesLogger.info(`Adding ${entity}: ${key} as ${namespacedKey}`)
273
+
274
+ namespacedDataObject[namespacedKey] = {
275
+ ...EntityNamespacer(
276
+ parentEntityObject[key],
277
+ fieldsToUpdate,
278
+ (fieldValueToUpdate: string | number) => UpdateValueWithNamespace(fieldValueToUpdate, packageID)
279
+ )
280
+ }
281
+
282
+ // do additional work if needed on newly namespaced nested object
283
+ if (nestedObjectUpdateCB) {
284
+ nestedObjectUpdateCB(namespacedDataObject[namespacedKey], key)
285
+ }
286
+
287
+ // add package information to top level of entity
288
+ namespacedDataObject[namespacedKey]["packageInfo"] = BuildPackageInfoForEntity(packageID, packageVersion)
289
+ })
290
+
291
+ return namespacedDataObject
292
+ }
293
+
294
+ ////////////////////////////////////////////////////////////////////
295
+ ///////////////// Namespacing function dictionary
296
+ ////////////////////////////////////////////////////////////////////
297
+
298
+ // to start packages will support import/removal of macros and steptypes
299
+ const WorkspaceEntityNamespaceFunctionLookup: INamespacerDictionary = {
300
+ macros: {
301
+ add: (
302
+ packageID: TPackageIdentifier,
303
+ packageVersion: TPackageVersion,
304
+ macros: Shared.MacroOperations.MacrosData
305
+ ): Shared.MacroOperations.MacrosData => MacrosNamespacer(packageID, macros, packageVersion),
306
+ remove: (
307
+ packageID: TPackageIdentifier,
308
+ macros: Shared.MacroOperations.MacrosData
309
+ ): Shared.MacroOperations.MacrosData => MacrosNamespaceRemover(packageID, macros)
310
+ },
311
+ stepTypes: {
312
+ add: (
313
+ packageID: TPackageIdentifier,
314
+ packageVersion: TPackageVersion,
315
+ stepTypes: Shared.StepTypes.AllStepTypesParsed
316
+ ): Shared.StepTypes.AllStepTypesParsed => StepTypesNamespacer(packageID, stepTypes, packageVersion),
317
+ remove: (
318
+ packageID: TPackageIdentifier,
319
+ stepTypes: Shared.StepTypes.AllStepTypesParsed
320
+ ): Shared.StepTypes.AllStepTypesParsed => StepTypesNamespaceRemover(packageID, stepTypes)
321
+ },
322
+ }
323
+
324
+ ////////////////////////////////////////////////////////////////////
325
+ ///////////////// Workspace Data Namespacing functions by entity (adding)
326
+ ////////////////////////////////////////////////////////////////////
327
+
328
+ const MacrosNamespacer = (packageID: TPackageIdentifier, macros: Shared.MacroOperations.MacrosData, packageVersion: TPackageVersion): Shared.MacroOperations.MacrosData => {
329
+ return NamespaceEntity(macros, packageID, packageVersion, [ "id" ], null, "macro")
330
+ }
331
+
332
+ const StepTypesNamespacer = (packageID: TPackageIdentifier, stepTypes: Shared.StepTypes.AllStepTypesParsed, packageVersion: TPackageVersion): Shared.StepTypes.AllStepTypesParsed => {
333
+ const filteredStepTypes = Object.keys(stepTypes)
334
+ .filter(stepTypeID => !Shared.StepTypes.isDefaultStepType(stepTypeID)) // remove default steptypes
335
+ .reduce((acc, id) => { // turn back into object
336
+ acc[id] = stepTypes[id]
337
+
338
+ return acc
339
+ }, {})
340
+
341
+ const updateStepTypeCB = (namespacedStepTypeData: Shared.StepTypes.StepTypeParsed) => {
342
+ namespacedStepTypeData.metadata["defaultStorageLocation"] = null
343
+ }
344
+
345
+ return NamespaceEntity(filteredStepTypes, packageID, packageVersion, [ "id" ], updateStepTypeCB, "stepType")
346
+ }
347
+
348
+ ////////////////////////////////////////////////////////////////////
349
+ ///////////////// Workspace Data Namespace Remover functions by entity (remove)
350
+ ////////////////////////////////////////////////////////////////////
351
+
352
+ const EntityRemover = (packageID: TPackageIdentifier, parentEntityObject: any, entityName: string) => {
353
+ const prunedEntityObject: any = {}
354
+
355
+ Object.keys(parentEntityObject)
356
+ .filter(entityKey => {
357
+ const entityPackageInfo: IEntityPackageInformation = Shared.Common.getValueSafe(parentEntityObject[entityKey], [ "packageInfo" ], null)
358
+
359
+ // if no info, then not imported, if info doesn't match, then not imported from this package
360
+ if (!entityPackageInfo || (!!entityPackageInfo && entityPackageInfo.id !== packageID)) {
361
+ return entityKey
362
+ } else {
363
+ PackagesLogger.info(`Removing ${entityName}: ${entityKey}`)
364
+ }
365
+ })
366
+ .forEach(prunedEntityKey => prunedEntityObject[prunedEntityKey] = parentEntityObject[prunedEntityKey])
367
+
368
+ return prunedEntityObject
369
+ }
370
+
371
+ const MacrosNamespaceRemover = (packageID: TPackageIdentifier, macros: Shared.MacroOperations.MacrosData): Shared.MacroOperations.MacrosData => {
372
+ return EntityRemover(packageID, macros, "macro")
373
+ }
374
+
375
+ const StepTypesNamespaceRemover = (packageID: TPackageIdentifier, stepTypes: Shared.StepTypes.AllStepTypesParsed): Shared.StepTypes.AllStepTypesParsed => {
376
+ return EntityRemover(packageID, stepTypes, "step type")
377
+ }
378
+
379
+ ////////////////////////////////////////////////////////////////////
380
+ ///////////////// Package/Dependency getters/builders
381
+ ////////////////////////////////////////////////////////////////////
382
+
383
+ const BuildPackageInfoForEntity = (packageID: TPackageIdentifier, version: TPackageVersion): IEntityPackageInformation => {
384
+ return {
385
+ id: packageID,
386
+ version
387
+ }
388
+ }
389
+
390
+ const InitializePackageDependencyInfoForPackage = (
391
+ id: TPackageIdentifier,
392
+ versionInfo: TVersionInfo,
393
+ version: TPackageVersion,
394
+ userID: string
395
+ ): IPackageInformation => {
396
+ return {
397
+ id,
398
+ version,
399
+ versionInfo,
400
+ status: EPackageStatus.adding,
401
+ createdAt: null, //timestamped on FS
402
+ addedBy: userID
403
+ }
404
+ }
405
+
406
+ ////////////////////////////////////////////////////////////////////
407
+ ///////////////// Workspace Data Collection functions
408
+ ////////////////////////////////////////////////////////////////////
409
+
410
+ const GetRepoNameFromURL = (repoURL: string) => {
411
+ try {
412
+ const nameIndex = repoURL.lastIndexOf("/") + 1
413
+ const gitIndex = repoURL.lastIndexOf(".git")
414
+ return repoURL.substring(nameIndex, gitIndex)
415
+ } catch (error) {
416
+ PackagesLogger.error(`Error when reading package dependency URL: ${repoURL}`, error)
417
+ throw error
418
+ }
419
+ }
420
+
421
+ const GetAllWorkspaceDataAndCommitHashFromRemoteRepository = (location: TPackageVersion): Promise<{ workspaceData: Shared.Workspaces.AllWorkspaceData, hashFromRemote: string | null }> => {
422
+ return new Promise((resolve, reject) => {
423
+ // ensure a unique temp folder name
424
+ const tempRepoFolder = `COA_TEMP_${GetRepoNameFromURL(location)}_${Math.random()}`
425
+ const tempFolderPath = os.tmpdir()
426
+ let commitHashToReturn: string
427
+
428
+ let commitOrBranch: string | null = null
429
+ let locationToUse: string = location
430
+
431
+ const indexOfHash = location.indexOf("#")
432
+ // if hash indicator, then grab commit and remove #info from URL
433
+ if (indexOfHash > -1) {
434
+ commitOrBranch = location.slice(indexOfHash + 1)
435
+ locationToUse = location.substring(0, indexOfHash)
436
+ }
437
+
438
+ const toExecute = commitOrBranch ? `&& git checkout ${commitOrBranch} && git log -n 1 ${commitOrBranch}` : `&& git log -n 1`
439
+
440
+ PackagesLogger.info(`Cloning Coalesce package from repo URL: ${locationToUse}`)
441
+
442
+ return Shared.Common.executeCommand(`cd ${tempFolderPath} && git clone ${locationToUse} ${tempRepoFolder} && ls && cd ${tempRepoFolder} && ls ${toExecute}`)
443
+ .then(res => {
444
+ const fileSystemSettings: Shared.GitOperations.Filesystem = { fs, dir: `${tempFolderPath}/${tempRepoFolder}` }
445
+ const getCommitFromGitLogRegex = new RegExp(/commit ([\s\S]*?)\n/)
446
+ const regexResult = getCommitFromGitLogRegex.exec(res[0])
447
+
448
+ if (!regexResult) {
449
+ const message = `No commit was found for ${commitOrBranch}`
450
+ throw new Error(message)
451
+ }
452
+
453
+ commitHashToReturn = regexResult[1]
454
+
455
+ return GetAllWorkspaceDataFromFileSystem(fileSystemSettings)
456
+ })
457
+ .then((workspaceData) => {
458
+ const additionalLogInfo = commitOrBranch ? ` at commit or branch: "${commitOrBranch}"` : ""
459
+ PackagesLogger.info(`Reading Coalesce package data from repo "${GetRepoNameFromURL(locationToUse)}"${additionalLogInfo}...`)
460
+
461
+ resolve({
462
+ workspaceData,
463
+ hashFromRemote: commitHashToReturn
464
+ })
465
+ })
466
+ .catch(error => {
467
+ PackagesLogger.error(`Error while collecting workspace data for repo ${GetRepoNameFromURL(locationToUse)}`, error)
468
+ reject(error)
469
+ })
470
+ })
471
+ }
472
+
473
+ const GetAllWorkspaceDataFromFileSystem = (fileSystemSettings: Shared.GitOperations.Filesystem) => {
474
+ return Shared.Workspaces.GetAllWorkspaceDataFromGitCommit(fileSystemSettings, null, null)
475
+ }
476
+
477
+ ////////////////////////////////////////////////////////////////////
478
+ ///////////////// Other helpers
479
+ ////////////////////////////////////////////////////////////////////
480
+
481
+ /**
482
+ * this function will remove any ws data associated with packageInfo from projectWorkspaceData
483
+ * before reinstalling to ensure up-to-date package information
484
+ *
485
+ * @param packageInfo
486
+ * @param packageWorkspaceData
487
+ * @param projectWorkspaceData
488
+ * @param packageContext
489
+ * @returns {
490
+ * desiredState: what FS should look like
491
+ * stateToReplace: what FS looks like now
492
+ * }
493
+ */
494
+ const GetNamespacedDataAndDesiredStateForFSFlush = (
495
+ packageInfo: IPackageInformation,
496
+ packageWorkspaceData: Shared.Workspaces.AllWorkspaceData,
497
+ projectWorkspaceData: Shared.Workspaces.AllWorkspaceData,
498
+ packageContext: IPackageContext
499
+ ): { desiredState: Shared.Workspaces.AllWorkspaceData, namespacedData: Shared.Workspaces.AllWorkspaceData } => {
500
+ if (!packageInfo.id) {
501
+ const message = `PackageID was ${packageInfo.id} when it should be a string`
502
+ Shared.Common.assert(Shared.Logging.LoggingArea.Packages, false, message)
503
+ throw new Error(message)
504
+ }
505
+
506
+ PackagesLogger.info(`Cleaning up any stale package data in your project...`)
507
+
508
+ // remove entities that were installed by current package
509
+ const prunedProjectWorkspaceData = RemoveNamespacedWorkspaceData(projectWorkspaceData, packageInfo, packageContext)
510
+ // get namespaced data for current package
511
+ const namespacedDataFromPackage = GetNamespacedWorkspaceData(packageWorkspaceData, packageInfo, packageContext)
512
+ // add namespaced data to project data
513
+ const projectWorkspaceDataWithUpdates = CombineWorkspaceDataByEntities(namespacedDataFromPackage, prunedProjectWorkspaceData)
514
+
515
+ return {
516
+ desiredState: projectWorkspaceDataWithUpdates,
517
+ namespacedData: namespacedDataFromPackage
518
+ }
519
+ }
520
+
521
+ // get a package context object utilized by much of the functionality in this file
522
+ export const InitializePackageContext = (
523
+ authInfo: Shared.ConnectionOperations.ITeamInfoAndFirebase,
524
+ environmentID: number
525
+ ): IPackageContext => {
526
+ return {
527
+ firestore: authInfo.firebase.firestore(),
528
+ timestamp: Shared.Firebase.serverTimestamp,
529
+ teamID: authInfo.teamInfo.fbTeamID,
530
+ environmentID,
531
+ loggerContext: { orgID: authInfo.teamInfo.fbTeamID, userID: authInfo.teamInfo.fbUserID }
532
+ }
533
+ }
534
+
535
+ const CombineWorkspaceDataByEntities = (
536
+ namespacedWorkspaceData: Shared.Workspaces.AllWorkspaceData,
537
+ projectWorkspaceData: Shared.Workspaces.AllWorkspaceData
538
+ ): Shared.Workspaces.AllWorkspaceData => {
539
+ let updatedProjectWorkspaceData = {}
540
+
541
+ Object.keys(projectWorkspaceData).forEach(entityKey => {
542
+ updatedProjectWorkspaceData[entityKey] = projectWorkspaceData[entityKey]
543
+ })
544
+
545
+ Object.keys(namespacedWorkspaceData).forEach(entityKey => {
546
+ updatedProjectWorkspaceData[entityKey] = { ...updatedProjectWorkspaceData[entityKey], ...namespacedWorkspaceData[entityKey] }
547
+ })
548
+
549
+ return updatedProjectWorkspaceData as Shared.Workspaces.AllWorkspaceData
550
+ }
551
+
552
+ const InitializeVersionInfo = (commitOrBranch: string | null, repoLocation: string): TVersionInfo => {
553
+ if (!!commitOrBranch) {
554
+ return {
555
+ commit: commitOrBranch,
556
+ }
557
+ } else {
558
+ return {
559
+ filePath: repoLocation,
560
+ }
561
+ }
562
+ }
563
+
564
+ const BuildManifest = (workspaceData: Shared.Workspaces.AllWorkspaceData): Shared.Workspaces.AllWorkspaceData => {
565
+ const manifest = {}
566
+
567
+ const getEntityNameFromPackageManifest = (entityType: string, entityID: string) => {
568
+ return Shared.Common.getValueSafe(workspaceData, [ entityType, entityID, "name" ], entityID);
569
+ }
570
+
571
+ Object.keys(workspaceData).forEach(entityType => {
572
+ const entityImportedData = Object.keys(workspaceData[entityType])
573
+
574
+ manifest[entityType] = {}
575
+
576
+ entityImportedData.forEach(namedEntityKey => {
577
+ debugger
578
+ manifest[entityType][namedEntityKey] = {
579
+ id: namedEntityKey,
580
+ name: getEntityNameFromPackageManifest(entityType, namedEntityKey)
581
+ }
582
+
583
+ })
584
+ })
585
+
586
+ return manifest as Shared.Workspaces.AllWorkspaceData
587
+ }
588
+
589
+ /**
590
+ * Returns a boolean telling whether the package being installed is already installed - note, does not differentiate by commit or branch, but detects by package name
591
+ * @param packages
592
+ * @param version
593
+ */
594
+ export const IsPackageAlreadyInstalled = (installedPackages: IPackages, packageID: string) => {
595
+ return packageID! in installedPackages
596
+ }
597
+
598
+ ////////////////////////////////////////////////////
599
+ ///////////// Firestore read/write funcs
600
+ ////////////////////////////////////////////////////
601
+
602
+ const GetPackageDocRefAdmin = (
603
+ packageInfo: IPackageInformation,
604
+ packageContext: IPackageContext,
605
+ ) => {
606
+ const { environmentID, teamID, firestore } = packageContext
607
+
608
+ return Shared.CommonOperations.getOrgConfigDocRefAdmin(firestore, teamID).collection("workspaces").doc(environmentID.toString()).collection("packages").doc(packageInfo.id)
609
+ }
610
+
611
+ const UpdatePackageInformationFS = (
612
+ packageInfo: IPackageInformation,
613
+ status: EPackageStatus,
614
+ packageContext: IPackageContext,
615
+ setTimestamp: boolean
616
+ ) => {
617
+ // example: setting timestamp once package has completed install
618
+ const createdAt = setTimestamp ? packageContext.timestamp : packageInfo.createdAt
619
+ const update = Object.assign({}, { ...packageInfo, status, createdAt }) as IPackageInformation
620
+
621
+ return GetPackageDocRefAdmin(packageInfo, packageContext).set(update)
622
+ .then(() => {
623
+ return { status, id: packageInfo.id }
624
+ })
625
+ }
626
+
627
+ const RemovePackageInformationFS = (
628
+ packageInfo: IPackageInformation,
629
+ packageContext: IPackageContext
630
+ ) => {
631
+ const { environmentID, teamID, firestore } = packageContext
632
+
633
+ return Shared.CommonOperations.getOrgConfigDocRefAdmin(firestore, teamID).collection("workspaces").doc(environmentID.toString()).collection("packages").doc(packageInfo.id).delete()
634
+ }
635
+
636
+ const FlushFirestoreWorkspaceWithAllWorkspaceDataForCLIPackages = (
637
+ firestore: firebase.default.firestore.Firestore,
638
+ teamID: string,
639
+ workspaceIDForTargetFlush: number,
640
+ desiredWorkspaceDataState: Shared.Workspaces.AllWorkspaceData,
641
+ workspaceDataToReplace: Shared.Workspaces.AllWorkspaceData
642
+ ) => {
643
+ // we do not want to compare packages when installing a package
644
+ delete desiredWorkspaceDataState.packages
645
+ delete workspaceDataToReplace.packages
646
+
647
+ return Shared.Workspaces.FlushFirestoreWorkspaceWithAllWorkspaceData(
648
+ firestore,
649
+ teamID,
650
+ workspaceIDForTargetFlush,
651
+ desiredWorkspaceDataState,
652
+ workspaceDataToReplace
653
+ )
654
+ }
655
+
656
+ export const GetPackageListMessageCLI = (packageContext: IPackageContext) => {
657
+ const { environmentID, teamID, firestore } = packageContext
658
+
659
+ return Shared.Workspaces.getAllWorkspaceDataFromFirebase(firestore, teamID, environmentID)
660
+ .then(wsData => {
661
+ const packages = Shared.Common.getValueSafe(wsData, [ "packages" ], null)
662
+
663
+ if (packages && Object.keys(packages).length) {
664
+ const listForCLI = Object.keys(packages).map(packageID => `${packageID} \n`)
665
+ listForCLI.unshift(`\nPackages on workspace ${environmentID}: \n`)
666
+ return listForCLI.join("")
667
+ } else {
668
+ return `No packages added to workspace ${environmentID}`
669
+ }
670
+ })
671
+ }
672
+
673
+ //////////////////////////////////////////////////////////
674
+ ///////////// install/remove kickoff functions and classes
675
+ //////////////////////////////////////////////////////////
676
+
677
+ export class PackageProvider {
678
+ id: string | null
679
+ version: TPackageVersion
680
+ loggerContext: Shared.Logging.LogContext
681
+ versionInfo: TVersionInfo | null
682
+ workspaceData: Shared.Workspaces.AllWorkspaceData | null
683
+ commitHash: string | null
684
+
685
+ constructor(dirOrURL: string, loggerContext: Shared.Logging.LogContext) {
686
+ this.version = dirOrURL
687
+ this.loggerContext = loggerContext
688
+ // these vars will be null until getWorkspaceDataFS is called
689
+ this.commitHash = null
690
+ this.id = null
691
+ this.workspaceData = null
692
+ this.versionInfo = null
693
+
694
+ // init logic
695
+ if (!(typeof this.version === "string")) {
696
+ const message = `Location should be a string, but is of type ${typeof this.version}`
697
+ PackagesLogger.errorContext(this.loggerContext, message)
698
+
699
+ throw new Error(`Invalid Package package location: ${message}`)
700
+ }
701
+ }
702
+
703
+ _getVersionType(): EVersionType {
704
+ const fileKeywordIndex = this.version.indexOf(localInstallKeyword)
705
+ const httpIndex = this.version.indexOf("http")
706
+
707
+ if (fileKeywordIndex === 0) {
708
+ return EVersionType.file
709
+ } else if (httpIndex === 0) {
710
+ return EVersionType.url
711
+ } else {
712
+ const message = `Error with URL or filepath: "${this.version}" - filepath missing prefix "filepath:" or URL missing "http://" or "https://"`
713
+ PackagesLogger.errorContext(this.loggerContext, message)
714
+ throw new Error(message)
715
+ }
716
+ }
717
+
718
+ _isProjectNameValid(name: string): boolean {
719
+ let isValid = true
720
+ const indexOfNamespace = name.indexOf("::")
721
+
722
+ // projectName should not be only whitespace
723
+ if (indexOfNamespace > -1) {
724
+ isValid = false
725
+ }
726
+
727
+ return isValid
728
+ }
729
+
730
+ getWorkspaceDataFS(): Promise<Shared.Workspaces.AllWorkspaceData> {
731
+ const versionType = this._getVersionType()
732
+ let wsDataFunc
733
+
734
+ switch (versionType) {
735
+ case EVersionType.file: {
736
+ const dirWithoutKeyword = this.version.substring(localInstallKeyword.length)
737
+ const fsSettings: Shared.GitOperations.Filesystem = { fs, dir: dirWithoutKeyword }
738
+
739
+ this.commitHash = null
740
+ wsDataFunc = GetAllWorkspaceDataFromFileSystem(fsSettings)
741
+
742
+ break
743
+ }
744
+ case EVersionType.url: {
745
+ wsDataFunc = GetAllWorkspaceDataAndCommitHashFromRemoteRepository(this.version)
746
+ .then((response) => {
747
+ const { workspaceData, hashFromRemote } = response
748
+
749
+ this.commitHash = hashFromRemote
750
+
751
+ return workspaceData
752
+ })
753
+ break
754
+ }
755
+ default: {
756
+ const message = `versionType was invariant: ${versionType}`
757
+ Shared.Common.assert(Shared.Logging.LoggingArea.Packages, false, message)
758
+ throw new Error(message)
759
+ }
760
+ }
761
+
762
+ return wsDataFunc
763
+ .then((workspaceData: Shared.Workspaces.AllWorkspaceData) => {
764
+ const projects = workspaceData.projects
765
+
766
+ if (!projects || !Object.keys(projects)) {
767
+ PackagesLogger.errorContext(this.loggerContext, `Invalid Package: Missing projects, cannot generate package ID`)
768
+ throw new Error(`Invalid Package: Missing projects, cannot generate package ID; Package owner must add a project name in Coalesce project settings and commit`)
769
+ }
770
+
771
+ // ensure package ID to be used as namespace, which is gathered from the default project name as of Lhotse 4.0
772
+ const projectName = Shared.ProjectOperations.GetDefaultProjectName(projects!)
773
+
774
+ if (!projectName) {
775
+ PackagesLogger.errorContext(this.loggerContext, `Invalid Package: Missing project name`)
776
+ throw new Error(`Invalid Package: Missing package ID; Package owner must add a project name in Coalesce project settings and commit`)
777
+ }
778
+
779
+ const defaultProject = projects![Shared.ProjectOperations.defaultProjectID] // currently only one default project
780
+ const isDefaultProjectValid = Shared.ProjectOperations.ValidateProjectAsRunType(defaultProject)
781
+
782
+ if (!isDefaultProjectValid.success) {
783
+ const errorMessage = `Invalid Package: projects failed Run Type validation - projects["1"]: ${defaultProject}`
784
+ PackagesLogger.errorContext(this.loggerContext, errorMessage, isDefaultProjectValid.errorString)
785
+
786
+ throw new Error(errorMessage)
787
+ }
788
+
789
+ // validate project name
790
+ if (!this._isProjectNameValid(projectName)) {
791
+ PackagesLogger.errorContext(this.loggerContext, `Invalid Package: Project name contains reserved namespacing characters "::"`)
792
+
793
+ throw new Error(`${projectName} contains "::" and is not a valid project name`)
794
+ }
795
+
796
+ this.id = projectName.toUpperCase() // should be uppercase
797
+ this.workspaceData = workspaceData
798
+ this.versionInfo = InitializeVersionInfo(this.commitHash, this.version)
799
+
800
+ return this.workspaceData
801
+ })
802
+ }
803
+ }
804
+
805
+ export const InstallPackage = (
806
+ packageProvider: PackageProvider,
807
+ packageContext: IPackageContext
808
+ ): Promise<void> => {
809
+ const { firestore, teamID, environmentID, loggerContext } = packageContext
810
+
811
+ return new Promise((resolve, reject) => {
812
+ let projectWorkspaceData: Shared.Workspaces.AllWorkspaceData
813
+ let packageDependencyInfo: IPackageInformation
814
+ let manifest: Shared.Workspaces.AllWorkspaceData
815
+
816
+ PackagesLogger.infoContext(loggerContext, `Gathering your project workspace data for environment: ${environmentID}`)
817
+
818
+ return Shared.Workspaces.getAllWorkspaceDataFromFirebase(firestore, teamID, environmentID)
819
+ .then((projectWorkspaceDataFromFirestore) => {
820
+ projectWorkspaceData = projectWorkspaceDataFromFirestore
821
+
822
+ return packageProvider.getWorkspaceDataFS()
823
+ })
824
+ .then(() => {
825
+ const { id, versionInfo } = packageProvider // at this point these vars have been defined
826
+
827
+ if (!versionInfo) {
828
+ const message = `versionInfo was ${versionInfo}`
829
+ Shared.Common.assert(Shared.Logging.LoggingArea.Packages, false, message)
830
+ throw new Error(message)
831
+ }
832
+
833
+ PackagesLogger.infoContext(loggerContext, `Installing package using namespace "${id!}::"`)
834
+
835
+ packageDependencyInfo = InitializePackageDependencyInfoForPackage(id!, versionInfo, packageProvider.version, loggerContext.userID! || "unknown")
836
+
837
+ return UpdatePackageInformationFS(packageDependencyInfo, EPackageStatus.adding, packageContext, false)
838
+ })
839
+ .then((packageInfoFS) => {
840
+ const { status, id } = packageInfoFS
841
+
842
+ PackagesLogger.infoContext(loggerContext, `${status} ${id}...building firestore updates payload...`)
843
+ const { desiredState, namespacedData } = GetNamespacedDataAndDesiredStateForFSFlush(packageDependencyInfo, packageProvider.workspaceData!, projectWorkspaceData, packageContext)
844
+
845
+ manifest = BuildManifest(namespacedData)
846
+
847
+ PackagesLogger.infoContext(loggerContext, "Applying updates to firestore...")
848
+ return FlushFirestoreWorkspaceWithAllWorkspaceDataForCLIPackages(
849
+ firestore,
850
+ teamID,
851
+ environmentID,
852
+ desiredState,
853
+ projectWorkspaceData
854
+ )
855
+ })
856
+ .then(() => {
857
+ return UpdatePackageInformationFS({ ...packageDependencyInfo, manifest }, EPackageStatus.added, packageContext, true)
858
+ })
859
+ .then((packageInfoFS) => {
860
+ const { status, id } = packageInfoFS
861
+ PackagesLogger.infoContext(loggerContext, `${status} ${id}...update successful!`)
862
+ resolve()
863
+ })
864
+ .catch(error => {
865
+ if (!!packageDependencyInfo) {
866
+ return UpdatePackageInformationFS(packageDependencyInfo, EPackageStatus.error, packageContext, false)
867
+ .then((packageInfo) => {
868
+ const { status, id } = packageInfo
869
+ PackagesLogger.errorContext(loggerContext, `Update failed for ${id}. Package ${id} status: ${status}, removing package information from project: `, error)
870
+ PackagesLogger.errorContext(loggerContext, error)
871
+
872
+ return UninstallPackage(id, packageContext)
873
+ })
874
+ .then(() => {
875
+ const message = `All data from package ${packageDependencyInfo.id} removed from project after error during installation.`
876
+ PackagesLogger.infoContext(loggerContext, message)
877
+ resolve()
878
+ })
879
+ .catch(error => reject(error))
880
+ } else {
881
+ PackagesLogger.errorContext(loggerContext, `Update failed when installing package from ${packageProvider.version}`, error)
882
+ reject(error)
883
+ }
884
+ })
885
+ })
886
+ }
887
+
888
+ export const UninstallPackage = (
889
+ packageID: TPackageIdentifier,
890
+ packageContext: IPackageContext
891
+ ): Promise<void> => {
892
+ const { firestore, teamID, environmentID, loggerContext } = packageContext
893
+
894
+ return new Promise((resolve, reject) => {
895
+ let projectWorkspaceData: Shared.Workspaces.AllWorkspaceData
896
+ let prunedWorkspaceData: Shared.Workspaces.AllWorkspaceData
897
+
898
+ PackagesLogger.infoContext(loggerContext, `Gathering your project workspace data for environment: ${environmentID}`)
899
+
900
+ return Shared.Workspaces.getAllWorkspaceDataFromFirebase(firestore, teamID, environmentID)
901
+ .then((projectWorkspaceDataFromFirestore) => {
902
+ projectWorkspaceData = projectWorkspaceDataFromFirestore
903
+ const allProjectDependencies: IPackages = Shared.Common.getValueSafe(projectWorkspaceDataFromFirestore, [ "packages" ], {})
904
+ const thisDependencyInfo = allProjectDependencies[packageID]
905
+
906
+ if (!thisDependencyInfo) {
907
+ const depKeys = Object.keys(allProjectDependencies)
908
+ reject(new Error(`Environment ${environmentID} has ${depKeys.length} packages installed: ${depKeys}. Package "${packageID}" is not installed to this environment.`))
909
+ return
910
+ }
911
+
912
+ PackagesLogger.infoContext(loggerContext, `Removing package workspace data from your project`)
913
+ prunedWorkspaceData = RemoveNamespacedWorkspaceData(projectWorkspaceData, thisDependencyInfo, packageContext)
914
+
915
+ return UpdatePackageInformationFS(thisDependencyInfo, EPackageStatus.removing, packageContext, false)
916
+ .then(() => {
917
+ PackagesLogger.infoContext(loggerContext, `Applying updates to firestore...`)
918
+
919
+ return FlushFirestoreWorkspaceWithAllWorkspaceDataForCLIPackages(
920
+ firestore,
921
+ teamID,
922
+ environmentID,
923
+ prunedWorkspaceData,
924
+ projectWorkspaceData
925
+ )
926
+ })
927
+ .then(() => {
928
+ return RemovePackageInformationFS(thisDependencyInfo, packageContext)
929
+ })
930
+ .then(() => {
931
+ PackagesLogger.infoContext(loggerContext, `Removed ${packageID} workspace data from your project`)
932
+ resolve()
933
+ })
934
+ .catch((error) => {
935
+ if (!!thisDependencyInfo) {
936
+ return UpdatePackageInformationFS(thisDependencyInfo, EPackageStatus.error, packageContext, false)
937
+ .then(() => {
938
+ PackagesLogger.errorContext(loggerContext, `Encountered a problem when removing ${thisDependencyInfo.id}, removal aborted. Please try again.`)
939
+ reject(error)
940
+ })
941
+ .catch(error => reject(error))
942
+ } else {
943
+ PackagesLogger.errorContext(loggerContext, `Update failed when removing package`, error)
944
+ reject(error)
945
+ }
946
+ })
947
+ })
948
+ })
949
+ }
950
+
951
+ ////////////////////////////////////////////////////
952
+ ///////////// helper Jest test funcs
953
+ ////////////////////////////////////////////////////
954
+
955
+ export const NamespaceWorkspaceData_Testing = (
956
+ allWorkspaceData: Shared.Workspaces.AllWorkspaceData,
957
+ packageInfo: IPackageInformation,
958
+ ): Shared.Workspaces.AllWorkspaceData => {
959
+ const { id, version } = packageInfo
960
+ const namespacedWorkspaceData = {}
961
+
962
+ Object.keys(allWorkspaceData).forEach(wsKey => {
963
+ try {
964
+ if (WorkspaceEntityNamespaceFunctionLookup[wsKey]) {
965
+ PackagesLogger.info(`JEST TEST: Preparing workspace data for import: ${wsKey}`)
966
+ namespacedWorkspaceData[wsKey] = WorkspaceEntityNamespaceFunctionLookup[wsKey].add(id, version, allWorkspaceData[wsKey])
967
+ }
968
+ } catch (error) {
969
+ const message = `JEST TEST: error when preparing workspace data for import: ${wsKey}`
970
+
971
+ if (error instanceof Error) {
972
+ PackagesLogger.error(message, error.message, error.name, error.stack, error)
973
+ } else {
974
+ PackagesLogger.error(message, error)
975
+ }
976
+
977
+ throw error
978
+ }
979
+ })
980
+
981
+ return namespacedWorkspaceData as Shared.Workspaces.AllWorkspaceData
982
+ }
983
+
984
+ // will remove namespaced package workspace data from allWorkspaceData and return the updated object
985
+ export const RemoveNamespacedWorkspaceData_Testing = (
986
+ allWorkspaceData: Shared.Workspaces.AllWorkspaceData,
987
+ packageInfo: IPackageInformation
988
+ ): Shared.Workspaces.AllWorkspaceData => {
989
+ const prunedWorkspaceData = {}
990
+ const { id } = packageInfo
991
+
992
+ Object.keys(allWorkspaceData).forEach(wsKey => {
993
+ try {
994
+ if (WorkspaceEntityNamespaceFunctionLookup[wsKey]) {
995
+ PackagesLogger.info(`JEST TEST: Pruning workspace data: ${wsKey}`)
996
+ prunedWorkspaceData[wsKey] = WorkspaceEntityNamespaceFunctionLookup[wsKey].remove(id, allWorkspaceData[wsKey])
997
+ } else {
998
+ prunedWorkspaceData[wsKey] = allWorkspaceData[wsKey]
999
+ }
1000
+ } catch (error) {
1001
+ const message = `JEST TEST: error when pruning workspace data for: ${wsKey}`
1002
+
1003
+ if (error instanceof Error) {
1004
+ PackagesLogger.error(message, error.message, error.name, error.stack, error)
1005
+ } else {
1006
+ PackagesLogger.error(message, error)
1007
+ }
1008
+
1009
+ throw error
1010
+ }
1011
+ })
1012
+
1013
+ return prunedWorkspaceData as Shared.Workspaces.AllWorkspaceData
1014
+ }
1015
+
1016
+ export const GetPackageSupportedEntityTypes = (): string[] => {
1017
+ return Object.keys(WorkspaceEntityNamespaceFunctionLookup)
1018
+ }