@leverege/build-tools 2.93.4 → 2.93.6

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.
@@ -0,0 +1,3 @@
1
+ {
2
+ "editor.formatOnSave": true
3
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leverege/build-tools",
3
- "version": "2.93.4",
3
+ "version": "2.93.6",
4
4
  "description": "A collection of build / support tools for Leverege developers",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -11,7 +11,8 @@
11
11
  "clean": "rm -rf lib",
12
12
  "lint": "find ./src -name \\*.\\*js | xargs npx eslint --report-unused-disable-directives",
13
13
  "prepare": "[ $SKIP_PREPARE ] && exit 0 || ./src/hook-and-release.mjs",
14
- "test": "mocha -t 60000 --exit 'test/**/*.mjs'",
14
+ "test": "mocha -t 60000 --exit 'test/**/!(*e2e*).mjs'",
15
+ "test-all": "mocha -t 60000 --exit 'test/**/**.mjs'",
15
16
  "prepack": "npm exec prepack"
16
17
  },
17
18
  "bin": {
@@ -104,4 +105,4 @@
104
105
  "mocha": "^11.7.5",
105
106
  "npm": "^11.7.0"
106
107
  }
107
- }
108
+ }
@@ -1,12 +1,14 @@
1
1
  /* eslint-disable security/detect-non-literal-fs-filename */
2
2
  import fs from 'node:fs'
3
+ import path from 'node:path'
3
4
 
4
5
  import { shellCmd, log, debug, err } from '../Utils.mjs'
5
6
 
6
- import { toExplicitPath, deduplicatePath, convertGenericArtifactNameToPath } from './Utils.mjs'
7
+ import { parseGenericArtifactSchemaToRemoteRelativePath, parseGenericArtifactSchemaToLocalRelativePath, listGenericArtifactFiles, parseGenericArtifactFileToRemoteRelativePath, parseGenericArtifactSchemaToArtifactName } from './Utils.mjs'
7
8
 
8
9
  class ArtifactPuller {
9
- constructor( artifact ) {
10
+ constructor( path, artifact ) {
11
+ this.path = path
10
12
  this.artifact = artifact
11
13
  }
12
14
 
@@ -17,142 +19,101 @@ class ArtifactPuller {
17
19
 
18
20
  class GenericArtifactPuller extends ArtifactPuller {
19
21
 
20
- async prepareFileDownload( tmpFilePath, finalFilePath ) {
21
- // Ensure directories exist
22
- fs.mkdirSync( tmpFilePath.split( '/' ).slice( 0, -1 ).join( '/' ), { recursive : true } )
23
- fs.mkdirSync( finalFilePath.split( '/' ).slice( 0, -1 ).join( '/' ), { recursive : true } )
22
+ async downloadFile( remoteRelativePath, localRelativePath ) {
23
+ debug( { remoteRelativePath, localRelativePath }, '<==Download File' )
24
24
 
25
- // Ensure no conflicting files exist
26
- if ( fs.existsSync( tmpFilePath ) ) {
27
- fs.unlinkSync( tmpFilePath )
28
- }
29
- if ( fs.existsSync( finalFilePath ) ) {
30
- fs.unlinkSync( finalFilePath )
31
- }
32
- }
25
+ const isBasenameDifferent = path.basename( localRelativePath ) !== path.basename( remoteRelativePath )
33
26
 
34
- async cleanupFileDownload( tmpFilePath, finalFilePath ) {
35
- // Delete the tmp file
36
- if ( fs.existsSync( tmpFilePath ) ) {
37
- fs.unlinkSync( tmpFilePath )
27
+ let tmpLocalRelativePath
28
+ if ( isBasenameDifferent ) {
29
+ const artifactName = parseGenericArtifactSchemaToArtifactName( this.artifact )
30
+ const encodedArtifactName = artifactName.replaceAll( ':', '_' ).replaceAll( '/', '_' ).replaceAll( '%2F', '_' )
31
+ tmpLocalRelativePath = path.join( '/', 'tmp', encodedArtifactName )
32
+ } else {
33
+ await fs.promises.mkdir( path.dirname( localRelativePath ), { recursive : true } )
38
34
  }
39
- // Delete the tmp directory
40
- if ( fs.existsSync( tmpFilePath.split( '/' ).slice( 0, -1 ).join( '/' ) ) ) {
41
- fs.rmSync( tmpFilePath.split( '/' ).slice( 0, -1 ).join( '/' ), { recursive : true } )
42
- }
43
- }
44
-
45
- async downloadFile( remoteFilePath, localFilePath ) {
46
- debug( { remoteFilePath, localFilePath }, '<==Download File' )
47
-
48
- // 1 dir per file to avoid dir creation and deletion race conditions for files in the same directory
49
- const tmpDirectoryPath = `/tmp/${remoteFilePath.replaceAll( '/', '_' ).replaceAll( '.', '_' )}`
50
- const tmpFilePath = `${tmpDirectoryPath}/${remoteFilePath.split( '/' ).pop()}`
51
-
52
- await this.prepareFileDownload( tmpFilePath, localFilePath )
53
35
 
54
36
  try {
55
37
  const command = `gcloud artifacts generic download \
56
- --project=${this.artifact.gcp_project_id} \
57
- --location=${this.artifact.gcp_location} \
58
- --repository=${this.artifact.gcp_artifact_registry_repository_name} \
59
- --package=${this.artifact.gcp_artifact_registry_package_name} \
60
- --version=${this.artifact.gcp_artifact_registry_package_version} \
61
- --name="${remoteFilePath}" \
62
- --destination=${tmpDirectoryPath}`
38
+ --project=${this.artifact.project} \
39
+ --location=${this.artifact.location} \
40
+ --repository=${this.artifact.repository} \
41
+ --package=${this.artifact.package} \
42
+ --version=${this.artifact.version} \
43
+ --name="${remoteRelativePath}" \
44
+ --destination=${tmpLocalRelativePath || path.dirname( localRelativePath )}`
63
45
 
64
46
  debug( { command }, '<==Download File Command' )
65
47
 
66
48
  await shellCmd( command )
67
49
 
68
- // Check if file exists in the tmp path
69
- if ( !fs.existsSync( tmpFilePath ) ) {
70
- debug( { tmpFilePath }, '<==File does not exist in the tmp path' )
71
- return false
72
- }
73
-
74
- debug( { tmpFilePath, localFilePath }, '<==Copying file' )
75
-
76
- await fs.copyFileSync( tmpFilePath, localFilePath )
77
-
78
- // Check if file exists in the local path
79
- if ( !fs.existsSync( localFilePath ) ) {
80
- debug( { localFilePath }, '<==File does not exist in the local path' )
81
- return false
50
+ if ( isBasenameDifferent ) {
51
+ await fs.promises.rename( tmpLocalRelativePath, localRelativePath )
82
52
  }
83
53
 
84
54
  return true
85
55
  } catch ( error ) {
86
- err( `Error downloading file: ${remoteFilePath} ${error.message}` )
56
+ err( `Error downloading file: ${localRelativePath} ${error.message}` )
87
57
  return false
88
- } finally {
89
- await this.cleanupFileDownload( tmpFilePath, localFilePath )
90
58
  }
91
59
  }
92
60
 
93
- async downloadDirectory( localPath ) {
61
+ async downloadDirectory( remoteRelativePath, localRelativePath ) {
62
+
63
+ await fs.promises.mkdir( localRelativePath, { recursive : true } )
64
+
94
65
  const command = `gcloud artifacts generic download \
95
- --project=${this.artifact.gcp_project_id} \
96
- --location=${this.artifact.gcp_location} \
97
- --repository=${this.artifact.gcp_artifact_registry_repository_name} \
98
- --package=${this.artifact.gcp_artifact_registry_package_name} \
99
- --version=${this.artifact.gcp_artifact_registry_package_version} \
100
- --destination=${localPath}`
66
+ --project=${this.artifact.project} \
67
+ --location=${this.artifact.location} \
68
+ --repository=${this.artifact.repository} \
69
+ --package=${this.artifact.package} \
70
+ --version=${this.artifact.version} \
71
+ --destination=${localRelativePath}`
101
72
 
102
73
  debug( { command }, '<==Download Directory Command' )
103
74
 
104
75
  await shellCmd( command )
105
76
  }
106
77
 
107
- async downloadNestedDirectory( remotePath, localPath ) {
108
- debug( { remotePath, localPath }, '<==Download Nested Directory' )
78
+ async downloadNestedDirectory( remoteRelativePath, localRelativePath ) {
79
+ debug( { remoteRelativePath, localRelativePath }, '<==Download Nested Directory' )
109
80
 
110
- const files = await this.listFiles( remotePath )
81
+ const files = await listGenericArtifactFiles( this.artifact )
111
82
  await Promise.all(
112
83
  files
113
- .filter( file => convertGenericArtifactNameToPath( file.name ).startsWith( remotePath ) )
114
- .map( async ( file ) => {
115
- const remoteFilePath = convertGenericArtifactNameToPath( file.name )
116
- const localFilePath = deduplicatePath( `${localPath}/${remoteFilePath.replaceAll( remotePath, '' )}` )
117
- await this.downloadFile( remoteFilePath, localFilePath )
118
- } )
84
+ .reduce( ( acc, file ) => {
85
+ const remoteFilePath = parseGenericArtifactFileToRemoteRelativePath( file )
86
+ if ( remoteFilePath.startsWith( remoteRelativePath ) ) {
87
+ const remoteFilePathWithoutRemotePath = remoteFilePath.replace( remoteRelativePath, '' )
88
+ const localFilePath = path.join( localRelativePath, remoteFilePathWithoutRemotePath )
89
+ acc.push( this.downloadFile( remoteFilePath, localFilePath ) )
90
+ }
91
+ return acc
92
+ }, [] )
119
93
  )
120
94
  }
121
95
 
122
- async listFiles( ) {
123
- const command = `gcloud artifacts files list \
124
- --project=${this.artifact.gcp_project_id} \
125
- --location=${this.artifact.gcp_location} \
126
- --repository=${this.artifact.gcp_artifact_registry_repository_name} \
127
- --package=${this.artifact.gcp_artifact_registry_package_name} \
128
- --version ${this.artifact.gcp_artifact_registry_package_version} \
129
- --format="json"`
130
-
131
- debug( { command }, '<==List Files Command' )
132
-
133
- const result = await shellCmd( command )
134
-
135
- // Parse the JSON response
136
- const files = JSON.parse( result )
137
- return files
138
- }
139
-
140
96
  async pull() {
141
97
  log( `Pulling artifact: ${this.artifact.name}` )
142
98
 
143
99
  debug( { artifact : this.artifact }, '<==Pulling artifact' )
144
100
 
145
101
  try {
102
+ const remoteRelativePath = parseGenericArtifactSchemaToRemoteRelativePath( this.artifact )
103
+ const localRelativePath = parseGenericArtifactSchemaToLocalRelativePath( this.artifact )
104
+
146
105
  if ( this.artifact.remote_file_path && this.artifact.local_file_path ) {
147
- await this.downloadFile( this.artifact.remote_file_path, toExplicitPath( this.artifact.local_file_path ) )
106
+ await this.downloadFile( remoteRelativePath, path.join( this.path, localRelativePath ) )
148
107
  } else if ( this.artifact.remote_file_path && this.artifact.local_path ) {
149
- await this.downloadFile( this.artifact.remote_file_path, toExplicitPath( `${this.artifact.local_path}/${this.artifact.remote_file_path.split( '/' ).pop()}` ) )
108
+ await this.downloadFile( remoteRelativePath, path.join( this.path, localRelativePath ) )
109
+ } else if ( this.artifact.remote_file_path && !this.artifact.local_path && !this.artifact.local_file_path ) {
110
+ await this.downloadFile( remoteRelativePath, path.join( this.path, localRelativePath ) )
150
111
  } else if ( this.artifact.remote_path && this.artifact.local_path ) {
151
- await this.downloadNestedDirectory( this.artifact.remote_path, toExplicitPath( this.artifact.local_path ) )
112
+ await this.downloadNestedDirectory( remoteRelativePath, path.join( this.path, localRelativePath ) )
152
113
  } else if ( this.artifact.remote_path && !this.artifact.local_path && !this.artifact.local_file_path ) {
153
- await this.downloadDirectory( this.artifact.remote_path, '.' )
114
+ await this.downloadDirectory( remoteRelativePath, path.join( this.path, localRelativePath ) )
154
115
  } else if ( !this.artifact.remote_path && !this.artifact.remote_file_path && this.artifact.local_path ) {
155
- await this.downloadDirectory( toExplicitPath( this.artifact.local_path ) )
116
+ await this.downloadDirectory( remoteRelativePath, path.join( this.path, localRelativePath ) )
156
117
  } else {
157
118
  throw new Error( 'No valid paths provided' )
158
119
  }
@@ -160,13 +121,6 @@ class GenericArtifactPuller extends ArtifactPuller {
160
121
  log( `Successfully pulled artifact: ${this.artifact.name}` )
161
122
 
162
123
  return true
163
-
164
- // if ( !this.artifact.remote_path && !this.artifact.remote_file_path && this.local_file_path ) {
165
- // throw new Error( '' )
166
- // }
167
- // if ( this.artifact.remote_path && this.artifact.local_file_path ) {
168
- // throw new Error( '' )
169
- // }
170
124
  } catch ( error ) {
171
125
  err( `Error pulling artifact: ${this.artifact.name} ${error.message}` )
172
126
  }
@@ -1,9 +1,20 @@
1
+ import path from 'node:path'
2
+ import fs from 'node:fs'
3
+
1
4
  import { shellCmd, log, err, debug } from '../Utils.mjs'
2
5
 
3
- import { convertGenericArtifactNameToPath } from './Utils.mjs'
6
+ import {
7
+ parseGenericArtifactFileToRemoteRelativePath,
8
+ parseGenericArtifactSchemaToRemoteRelativePath,
9
+ listGenericArtifactFiles,
10
+ shouldUseIgnoreConfiguration,
11
+ findIgnoreFilePath,
12
+ copyFilesWithIgnoreConfiguration
13
+ } from './Utils.mjs'
4
14
 
5
15
  class ArtifactPusher {
6
- constructor( artifact ) {
16
+ constructor( path, artifact ) {
17
+ this.path = path
7
18
  this.artifact = artifact
8
19
  }
9
20
 
@@ -13,25 +24,6 @@ class ArtifactPusher {
13
24
  }
14
25
 
15
26
  class GenericArtifactPusher extends ArtifactPusher {
16
-
17
- async listFiles( ) {
18
- const command = `gcloud artifacts files list \
19
- --project=${this.artifact.gcp_project_id} \
20
- --location=${this.artifact.gcp_location} \
21
- --repository=${this.artifact.gcp_artifact_registry_repository_name} \
22
- --package=${this.artifact.gcp_artifact_registry_package_name} \
23
- --version ${this.artifact.gcp_artifact_registry_package_version} \
24
- --format="json"`
25
-
26
- debug( { command }, '<==List Files Command' )
27
-
28
- const result = await shellCmd( command )
29
-
30
- // Parse the JSON response
31
- const files = JSON.parse( result )
32
-
33
- return files
34
- }
35
27
 
36
28
  async push() {
37
29
  log( `Pushing artifact: ${this.artifact.name}` )
@@ -41,41 +33,74 @@ class GenericArtifactPusher extends ArtifactPusher {
41
33
  return false
42
34
  }
43
35
 
44
- try {
45
-
36
+ if ( !this.artifact.local_path && !this.artifact.local_file_path ) {
37
+ err( 'Local path or local file path is required for pushing artifacts' )
38
+ return false
39
+ }
40
+
41
+ try {
46
42
  let command = ''
47
43
 
48
44
  command = `gcloud artifacts generic upload \
49
- --project=${this.artifact.gcp_project_id} \
50
- --location=${this.artifact.gcp_location} \
51
- --repository=${this.artifact.gcp_artifact_registry_repository_name} \
52
- --package=${this.artifact.gcp_artifact_registry_package_name} \
53
- --version=${this.artifact.gcp_artifact_registry_package_version}`
45
+ --project=${this.artifact.project} \
46
+ --location=${this.artifact.location} \
47
+ --repository=${this.artifact.repository} \
48
+ --package=${this.artifact.package} \
49
+ --version=${this.artifact.version}`
50
+
51
+ let pathToDelete
54
52
 
55
53
  if ( this.artifact.local_path ) {
56
- command += ` --source-directory=${this.artifact.local_path}`
54
+ const originalLocalPath = path.join( this.path, this.artifact.local_path )
55
+
56
+ let localPath = originalLocalPath
57
+
58
+ const useIgnoreConfiguration = await shouldUseIgnoreConfiguration()
59
+
60
+ if ( useIgnoreConfiguration ) {
61
+ const ignoreFilePath = await findIgnoreFilePath( this.path )
62
+ if ( ignoreFilePath ) {
63
+ const tmpLocalPath = path.join( '/', 'tmp', `${this.artifact.project}_${this.artifact.location}_${this.artifact.repository}_${this.artifact.package}_${this.artifact.version}` )
64
+ localPath = tmpLocalPath
65
+ pathToDelete = tmpLocalPath
66
+ await copyFilesWithIgnoreConfiguration( originalLocalPath, tmpLocalPath, ignoreFilePath )
67
+ }
68
+ }
69
+
70
+ command += ` --source-directory=${localPath}`
57
71
  command += ' --skip-existing'
58
72
  } else if ( this.artifact.local_file_path ) {
59
- const files = await this.listFiles( this.artifact.remote_path )
60
- if ( files.some( file => convertGenericArtifactNameToPath( file.name ) === `${this.artifact.remote_path}/${this.artifact.remote_file_path.split( '/' ).pop()}` ) ) {
61
- err( `File already exists in the artifact registry: ${this.artifact.local_file_path}` )
62
- return false
73
+
74
+ const files = await listGenericArtifactFiles( this.artifact )
75
+
76
+ const newRemoteFilePath = parseGenericArtifactSchemaToRemoteRelativePath( this.artifact )
77
+
78
+ for ( const file of files ) {
79
+ const existingRemoteFilePath = parseGenericArtifactFileToRemoteRelativePath( file )
80
+ if ( existingRemoteFilePath === newRemoteFilePath ) {
81
+ err( `File already exists in the artifact registry: ${existingRemoteFilePath}` )
82
+ return false
83
+ }
63
84
  }
64
- command += ` --source=${this.artifact.local_file_path}`
85
+ const localPath = path.join( this.path, this.artifact.local_file_path )
86
+ command += ` --source=${localPath}`
65
87
  }
66
88
 
67
- if ( this.artifact.remote_path ) {
68
- command += ` --destination-path=${this.artifact.remote_path}`
69
- }
89
+ command += ` --destination-path=${this.artifact.remote_path || '""'}`
70
90
 
71
91
  debug( { command }, '<==Upload Command' )
72
92
 
73
93
  await shellCmd( command )
74
-
94
+
95
+ if ( pathToDelete ) {
96
+ await fs.promises.rm( pathToDelete, { recursive : true } )
97
+ }
98
+
75
99
  log( `Successfully pushed artifact: ${this.artifact.name}` )
76
100
 
77
101
  return true
78
102
  } catch ( error ) {
103
+ debug( { error }, '<==Error Pushing Artifact' )
79
104
  err( `Error pushing artifact: ${this.artifact.name} ${error.message}` )
80
105
  return false
81
106
  }
@@ -1,37 +1,42 @@
1
- import { assign, object, string, array, optional, enums, union, refine } from 'superstruct'
1
+ import path from 'node:path'
2
+
3
+ import { assign, coerce, object, string, array, optional, enums, union, refine } from 'superstruct'
2
4
 
3
5
  const ArtifactTypeEnum = {
4
6
  GENERIC : 'generic',
5
7
  }
6
8
 
7
- // Superstruct schema for individual artifact configuration
9
+ // Structs with the Partial prefix come from the need to validate the schema with defaults and without defaults merged together.
10
+
8
11
  const PartialArtifactSchema = object( {
9
12
  name : string(),
10
13
  type : optional( enums( Object.values( ArtifactTypeEnum ) ) ),
11
- gcp_project_id : optional( string() ),
12
- gcp_location : optional( string() ),
13
- gcp_artifact_registry_repository_name : optional( string() ),
14
- gcp_artifact_registry_package_name : optional( string() ),
15
- gcp_artifact_registry_package_version : optional( string() )
14
+ project : optional( string() ),
15
+ location : optional( string() ),
16
+ repository : optional( string() ),
17
+ package : optional( string() ),
18
+ version : optional( string() )
16
19
  } )
17
20
 
18
21
  const ArtifactSchema = assign( PartialArtifactSchema, object( {
19
22
  name : string(),
20
23
  type : enums( Object.values( ArtifactTypeEnum ) ),
21
- gcp_project_id : string(),
22
- gcp_location : string(),
23
- gcp_artifact_registry_repository_name : string(),
24
- gcp_artifact_registry_package_name : string(),
25
- gcp_artifact_registry_package_version : string()
24
+ project : string(),
25
+ location : string(),
26
+ repository : string(),
27
+ package : string(),
28
+ version : string()
26
29
  } ) )
27
30
 
31
+ const normalizedPath = () => coerce( string(), string(), value => path.normalize( value ) )
32
+
28
33
  const PartialGenericArtifactSchema = refine(
29
34
  assign( PartialArtifactSchema, object( {
30
- type : enums( [ ArtifactTypeEnum.GENERIC ] ),
31
- local_file_path : optional( string() ),
32
- local_path : optional( string() ),
33
- remote_file_path : optional( string() ),
34
- remote_path : optional( string() )
35
+ type : optional( enums( [ ArtifactTypeEnum.GENERIC ] ) ),
36
+ local_file_path : optional( normalizedPath() ),
37
+ local_path : optional( normalizedPath() ),
38
+ remote_file_path : optional( normalizedPath() ),
39
+ remote_path : optional( normalizedPath() )
35
40
  } ) ),
36
41
  'ConflictingPaths',
37
42
  ( value ) => {
@@ -47,10 +52,11 @@ const PartialGenericArtifactSchema = refine(
47
52
 
48
53
  const GenericArtifactSchema = refine(
49
54
  assign( ArtifactSchema, object( {
50
- local_file_path : optional( string() ),
51
- local_path : optional( string() ),
55
+ type : enums( [ ArtifactTypeEnum.GENERIC ] ),
56
+ local_file_path : optional( normalizedPath() ),
57
+ local_path : optional( normalizedPath() ),
52
58
  remote_file_path : optional( string() ),
53
- remote_path : optional( string() )
59
+ remote_path : optional( normalizedPath() )
54
60
  } ) ),
55
61
  'ConflictingPaths',
56
62
  ( value ) => {
@@ -66,11 +72,12 @@ const GenericArtifactSchema = refine(
66
72
 
67
73
  // Superstruct schema for defaults configuration
68
74
  const DefaultsSchema = object( {
69
- gcp_project_id : optional( string() ),
70
- gcp_location : optional( string() ),
71
- gcp_artifact_registry_repository_name : optional( string() ),
72
- gcp_artifact_registry_package_name : optional( string() ),
73
- gcp_artifact_registry_package_version : optional( string() )
75
+ type : optional( enums( Object.values( ArtifactTypeEnum ) ) ),
76
+ project : optional( string() ),
77
+ location : optional( string() ),
78
+ repository : optional( string() ),
79
+ package : optional( string() ),
80
+ version : optional( string() )
74
81
  } )
75
82
 
76
83
  // Superstruct schema for the complete artifacts configuration with defaults
@@ -1,16 +1,19 @@
1
1
  import fs from 'node:fs'
2
+ import path from 'node:path'
2
3
  import crypto from 'crypto'
3
4
 
4
5
  import { validate } from 'superstruct'
5
6
 
6
- import { parseYamlFile, warning } from '../Utils.mjs'
7
+ import { parseYamlFile, err, warning, debug, shellCmd } from '../Utils.mjs'
7
8
 
8
9
  import { ArtifactTypeEnum, ArtifactsSchema, GenericArtifactSchema } from './Structs.mjs'
9
10
 
10
- const getArtifactsSchema = async ( artifactsFilePath = './artifacts.yml' ) => {
11
+ const getArtifactsSchema = async ( artifactsFilePath = './artifacts.yaml' ) => {
11
12
  const artifactsConfig = await parseYamlFile( artifactsFilePath )
12
13
  const [ error, result ] = validate( artifactsConfig, ArtifactsSchema )
13
14
  if ( error ) {
15
+ err( `Invalid artifacts.yml file: ${error.message}` )
16
+ debug( { err : error }, '<==Invalid artifacts.yml file' )
14
17
  throw new Error( `Invalid artifacts.yml file: ${error.message}` )
15
18
  }
16
19
  return result
@@ -22,7 +25,7 @@ const flattenArtifactsSchema = ( artifactsSchema ) => {
22
25
  ...artifactsSchema.defaults,
23
26
  ...artifact
24
27
  }
25
-
28
+
26
29
  let schema
27
30
  switch ( mergedArtifact.type ) {
28
31
  case ArtifactTypeEnum.GENERIC:
@@ -49,44 +52,230 @@ const flattenArtifactsSchema = ( artifactsSchema ) => {
49
52
  }, [] )
50
53
  }
51
54
 
52
- const toImplicitPath = ( path ) => {
53
- let implicitPath = path
54
- // Remove leading './' if present
55
- if ( implicitPath.startsWith( './' ) ) {
56
- implicitPath = implicitPath.slice( 2 )
55
+ // Example of a file in a generic artifact registry repository:
56
+ // [
57
+ // {
58
+ // "createTime": "2025-12-02T17:52:08.694541Z",
59
+ // "hashes": [
60
+ // {
61
+ // "type": "SHA256",
62
+ // "value": "wrBRqJOUjm-zy6B7YzgKlK0fv9Ad0EEjydBnsmc9Ruw="
63
+ // },
64
+ // {
65
+ // "type": "MD5",
66
+ // "value": "Ebp3fVphzrITJ461Mg7Kmg=="
67
+ // }
68
+ // ],
69
+ // "name": "projects/leverege-registry/locations/us-central1/repositories/test-generic-registry/files/sweet-poems-1-1764697925426:v1:test1.txt%2Ftest1.txt",
70
+ // "owner": "projects/leverege-registry/locations/us-central1/repositories/test-generic-registry/packages/sweet-poems-1-1764697925426/versions/v1",
71
+ // "sizeBytes": "40",
72
+ // "updateTime": "2025-12-02T17:52:08.694541Z"
73
+ // }
74
+ // ]
75
+ // Obtained with:gcloud artifacts files list --project=leverege-registry --location=us-central1 --repository=test-generic-registry --package=sweet-poems-1-1764697925426 --version v1 --format="json"
76
+
77
+ const parseGenericArtifactFileToRemoteRelativePath = ( genericArtifactFile ) => {
78
+ // "projects/leverege-registry/locations/us-central1/repositories/test-generic-registry/files/sweet-poems-1-1764697925426:v1:test1.txt%2Ftest1.txt"
79
+ const artifactFileName = genericArtifactFile.name.split( '/' ).pop()
80
+ // sweet-poems-1-1764697925426:v1:test1.txt%2Ftest1.txt
81
+ const remoteRelativePath = artifactFileName.split( ':' ).pop().replaceAll( '%2F', '/' )
82
+ // test1.txt
83
+ return path.normalize( remoteRelativePath )
84
+ }
85
+
86
+ const parseGenericArtifactSchemaToRemoteRelativePath = ( genericArtifactSchema ) => {
87
+ if ( genericArtifactSchema.remote_file_path ) {
88
+ return path.normalize( genericArtifactSchema.remote_file_path )
89
+ }
90
+ if ( genericArtifactSchema.remote_path && genericArtifactSchema.local_file_path ) {
91
+ return path.join( genericArtifactSchema.remote_path, path.basename( genericArtifactSchema.local_file_path ) )
92
+ }
93
+ if ( !genericArtifactSchema.remote_file_path && !genericArtifactSchema.remote_path && genericArtifactSchema.local_file_path ) {
94
+ return path.basename( genericArtifactSchema.local_file_path )
57
95
  }
58
- // Remove trailing slash
59
- implicitPath = implicitPath.replace( /\/+$/, '' )
60
- return implicitPath
96
+ if ( !genericArtifactSchema.remote_file_path && !genericArtifactSchema.remote_path && genericArtifactSchema.local_file_path ) {
97
+ return path.basename( genericArtifactSchema.local_file_path )
98
+ }
99
+ if ( genericArtifactSchema.remote_path && genericArtifactSchema.local_path ) {
100
+ return path.normalize( genericArtifactSchema.remote_path )
101
+ }
102
+ return path.normalize( '.' )
61
103
  }
62
104
 
63
- const toExplicitPath = ( path ) => {
64
- let explicitPath = path
65
- // Keep absolute paths and paths already starting with '.' as-is
66
- if ( !path.startsWith( '/' ) && !path.startsWith( '.' ) ) {
67
- // Add './' to relative paths
68
- explicitPath = `./${explicitPath}`
105
+ const parseGenericArtifactSchemaToLocalRelativePath = ( genericArtifactSchema ) => {
106
+ if ( genericArtifactSchema.local_file_path ) {
107
+ return path.normalize( genericArtifactSchema.local_file_path )
108
+ }
109
+ if ( genericArtifactSchema.local_path && genericArtifactSchema.remote_file_path ) {
110
+ return path.join( genericArtifactSchema.local_path, path.basename( genericArtifactSchema.remote_file_path ) )
69
111
  }
70
- return explicitPath
112
+ if ( genericArtifactSchema.local_path && !genericArtifactSchema.remote_file_path && !genericArtifactSchema.remote_file_path ) {
113
+ return path.normalize( genericArtifactSchema.local_path )
114
+ }
115
+ if ( !genericArtifactSchema.local_file_path && !genericArtifactSchema.local_path && genericArtifactSchema.remote_file_path ) {
116
+ return path.basename( genericArtifactSchema.remote_file_path )
117
+ }
118
+ return path.normalize( '.' )
71
119
  }
72
120
 
73
- const deduplicatePath = ( path ) => {
74
- return path.replaceAll( '//', '/' )
121
+ const parseGenericArtifactSchemaToArtifactNameBase = ( genericArtifactSchema ) => {
122
+ const elements = [
123
+ 'projecs',
124
+ genericArtifactSchema.project,
125
+ 'locations',
126
+ genericArtifactSchema.location,
127
+ 'repositories',
128
+ genericArtifactSchema.repository,
129
+ 'files',
130
+ ]
131
+ return elements.join( '/' )
75
132
  }
76
133
 
77
- const convertGenericArtifactNameToPath = ( genericArtifactName ) => {
78
- return genericArtifactName.split( ':' ).pop().replaceAll( '%2F', '/' ).replaceAll( '%3A', ':' )
134
+ const parseGenericArtifactSchemaToArtifactNameFile = ( genericArtifactSchema ) => {
135
+ // // "name": "projects/leverege-registry/locations/us-central1/repositories/test-generic-registry/files/sweet-poems-1-1764697925426:v1:test1.txt%2Ftest1.txt",
136
+
137
+ const remoteRelativePath = parseGenericArtifactSchemaToRemoteRelativePath( genericArtifactSchema )
138
+ const encodedRemoteRelativePath = encodeURIComponent( remoteRelativePath )
139
+
140
+ const elements = [
141
+ genericArtifactSchema.package,
142
+ genericArtifactSchema.version,
143
+ encodedRemoteRelativePath,
144
+ ]
145
+ return elements.join( ':' )
146
+ }
147
+
148
+ const parseGenericArtifactSchemaToArtifactName = ( genericArtifactSchema ) => {
149
+ const base = parseGenericArtifactSchemaToArtifactNameBase( genericArtifactSchema )
150
+ const file = parseGenericArtifactSchemaToArtifactNameFile( genericArtifactSchema )
151
+
152
+ const elements = [
153
+ base,
154
+ file,
155
+ ]
156
+
157
+ return elements.join( ':' )
79
158
  }
80
159
 
81
- const calculateHash = ( path ) => {
160
+ const calculateHash = async ( path ) => {
82
161
  try {
83
162
  const hash = crypto.createHash( 'sha256' )
84
- hash.update( fs.readFileSync( path ) )
85
- return hash.digest( 'base64url' )
163
+ hash.update( await fs.promises.readFile( path ) )
164
+ return `${hash.digest( 'base64url' )}=`
86
165
  } catch ( error ) {
87
- console.error( error )
88
166
  throw new Error( `Failed to calculate hash for artifact ${path}: ${error.message}` )
89
167
  }
90
168
  }
91
169
 
92
- export { calculateHash, getArtifactsSchema, flattenArtifactsSchema, toImplicitPath, toExplicitPath, deduplicatePath, convertGenericArtifactNameToPath }
170
+ const listGenericArtifactFiles = async ( artifact ) => {
171
+ const command = `gcloud artifacts files list \
172
+ --project=${artifact.project} \
173
+ --location=${artifact.location} \
174
+ --repository=${artifact.repository} \
175
+ --package=${artifact.package} \
176
+ --version ${artifact.version} \
177
+ --format="json"`
178
+
179
+ debug( { command }, '<==List Files Command' )
180
+
181
+ const result = await shellCmd( command )
182
+
183
+ // Parse the JSON response
184
+ const files = JSON.parse( result )
185
+ return files
186
+ }
187
+
188
+ const getMissingRemoteFiles = async ( genericArtifactFiles, genericArtifactSchemaPath, genericArtifactSchema ) => {
189
+
190
+ const expectedRemoteRelativePath = parseGenericArtifactSchemaToRemoteRelativePath( genericArtifactSchema )
191
+ const localRelativePath = parseGenericArtifactSchemaToLocalRelativePath( genericArtifactSchema )
192
+ const expectedLocalRelativePath = path.join( genericArtifactSchemaPath, localRelativePath )
193
+
194
+ const actualFiles = new Set()
195
+ const expectedFiles = new Set()
196
+
197
+ for ( const file of genericArtifactFiles ) {
198
+ const actualRemoteFilePath = parseGenericArtifactFileToRemoteRelativePath( file )
199
+ const actualRemoteFileHash = file.hashes.find( hash => hash.type === 'SHA256' ).value
200
+ actualFiles.add( { remoteFilePath : actualRemoteFilePath, remoteFileHash : actualRemoteFileHash } )
201
+ }
202
+
203
+ if ( ( await fs.promises.stat( expectedLocalRelativePath ) ).isDirectory() ) {
204
+ const localFiles = await fs.promises.readdir( expectedLocalRelativePath, { withFileTypes : true, recursive : true } )
205
+ for ( const localFile of localFiles ) {
206
+ if ( localFile.isDirectory() ) {
207
+ continue
208
+ }
209
+
210
+ const expectedRemoteFilePath = path.join( expectedRemoteRelativePath, localFile.path.replace( expectedLocalRelativePath, '' ), localFile.name )
211
+ const expectedLocalFilePath = path.join( localFile.path, localFile.name )
212
+ expectedFiles.add( { remoteFilePath : expectedRemoteFilePath, remoteFileHash : await calculateHash( expectedLocalFilePath ) } )
213
+ }
214
+ } else {
215
+ const expectedRemoteFilePath = expectedRemoteRelativePath
216
+ const expectedLocalFilePath = expectedLocalRelativePath
217
+ expectedFiles.add( { remoteFilePath : expectedRemoteFilePath, remoteFileHash : await calculateHash( expectedLocalFilePath ) } )
218
+ }
219
+
220
+ const actualArray = Array.from( actualFiles )
221
+ const expectedArray = Array.from( expectedFiles )
222
+
223
+ return expectedArray.filter( expected => !actualArray.some( actual => actual.remoteFilePath === expected.remoteFilePath && actual.remoteFileHash === expected.remoteFileHash ) )
224
+ }
225
+
226
+ const shouldUseIgnoreConfiguration = async () => {
227
+ let result
228
+ try {
229
+ result = await shellCmd( 'gcloud config get-value gcloudignore/enabled' )
230
+ if ( result.trim() === '' || result.includes( 'has no property' ) ) {
231
+ return false
232
+ }
233
+ return result.trim() === 'true'
234
+ } catch ( error ) {
235
+ if ( error.message && error.message.includes( 'Section [gcloudignore] has no property [enabled]' ) ) {
236
+ return false
237
+ }
238
+ // Re-throw other errors
239
+ throw error
240
+ }
241
+ }
242
+
243
+ const findIgnoreFilePath = async ( relativePath ) => {
244
+ if ( relativePath ) {
245
+ const ignoreFilePath = path.join( relativePath, '.gcloudignore' )
246
+ if ( fs.existsSync( ignoreFilePath ) ) {
247
+ return ignoreFilePath
248
+ }
249
+ }
250
+
251
+ const ignoreFilePath = path.join( process.cwd(), '.gcloudignore' )
252
+ if ( fs.existsSync( ignoreFilePath ) ) {
253
+ return ignoreFilePath
254
+ }
255
+
256
+ return null
257
+ }
258
+
259
+ const copyFilesWithIgnoreConfiguration = async ( sourcePath, destinationPath, ignoreFilePath ) => {
260
+ // Ensure sourcePath ends with / so rsync copies contents (not the folder itself) and exclude patterns work correctly
261
+ const normalizedSourcePath = sourcePath.endsWith( '/' ) ? sourcePath : `${sourcePath}/`
262
+ const command = `rsync -av --delete --exclude-from='${ignoreFilePath}' ${normalizedSourcePath} ${destinationPath}`
263
+ await shellCmd( command )
264
+ }
265
+
266
+ export {
267
+ calculateHash,
268
+ getArtifactsSchema,
269
+ flattenArtifactsSchema,
270
+ parseGenericArtifactFileToRemoteRelativePath,
271
+ parseGenericArtifactSchemaToRemoteRelativePath,
272
+ parseGenericArtifactSchemaToLocalRelativePath,
273
+ parseGenericArtifactSchemaToArtifactNameBase,
274
+ parseGenericArtifactSchemaToArtifactNameFile,
275
+ parseGenericArtifactSchemaToArtifactName,
276
+ getMissingRemoteFiles,
277
+ listGenericArtifactFiles,
278
+ shouldUseIgnoreConfiguration,
279
+ findIgnoreFilePath,
280
+ copyFilesWithIgnoreConfiguration
281
+ }
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import path from 'node:path'
4
+
3
5
  import { program } from 'commander'
4
6
 
5
7
  import { warning, log } from '../Utils.mjs'
@@ -11,33 +13,36 @@ import { GenericArtifactPuller } from './ArtifactPuller.mjs'
11
13
  program
12
14
  .name( 'pull-artifact' )
13
15
  .description( 'Pull artifacts from GCP Artifact Registry' )
14
- .requiredOption( '-f, --file-path <path>', 'Path to artifacts.yaml file' )
15
- .option( '-n, --package-name <name>', 'Filter by artifact package name' )
16
- .option( '-v, --package-version <version>', 'Filter by artifact package version' )
16
+ .option( '-f, --file-path <file-path>', 'Path to artifacts.yaml file', './artifacts.yaml' )
17
+ .option( '-a, --artifact-path <artifact-path>', 'GCP artifact path' )
17
18
  .parse()
18
19
 
19
20
  const options = program.opts()
20
21
 
21
22
  async function main() {
22
- const artifactsYmlPath = options.filePath
23
- const artifactPackageName = options.packageName || null
24
- const artifactPackageVersion = options.packageVersion || null
25
-
26
- const artifactsSchema = await getArtifactsSchema( artifactsYmlPath )
23
+ const artifactsSchema = await getArtifactsSchema( options.filePath )
27
24
  const flattenedArtifactsSchema = flattenArtifactsSchema( artifactsSchema )
28
-
29
- let filteredArtifactsSchema
30
- if ( artifactPackageName && artifactPackageVersion ) {
31
- filteredArtifactsSchema = flattenedArtifactsSchema.filter(
32
- artifact => artifact.gcp_artifact_registry_package_name === artifactPackageName &&
33
- artifact.gcp_artifact_registry_package_version === artifactPackageVersion )
34
- } else if ( artifactPackageName ) {
25
+
26
+ let filteredArtifactsSchema = flattenedArtifactsSchema
27
+
28
+ if ( options.artifactPath ) {
29
+ const artifactPathElements = options.artifactPath.split( '/' )
30
+
31
+ if ( artifactPathElements.length !== 5 ) {
32
+ warning( 'Invalid artifact path, it must be in the format <project>/<location>/<repository>/<package>/<version>' )
33
+ return false
34
+ }
35
+
36
+ const [ project, location, repository, packageName, version ] = artifactPathElements
37
+
35
38
  filteredArtifactsSchema = flattenedArtifactsSchema.filter(
36
- artifact => artifact.gcp_artifact_registry_package_name === artifactPackageName )
37
- } else {
38
- filteredArtifactsSchema = flattenedArtifactsSchema
39
+ artifact => artifact.project === project &&
40
+ artifact.location === location &&
41
+ artifact.repository === repository &&
42
+ artifact.package === packageName &&
43
+ artifact.version === version )
39
44
  }
40
-
45
+
41
46
  let counter = 0
42
47
 
43
48
  const pullResults = await Promise.all(
@@ -45,7 +50,7 @@ async function main() {
45
50
  let puller
46
51
  switch ( artifactSchema.type ) {
47
52
  case ArtifactTypeEnum.GENERIC:
48
- puller = new GenericArtifactPuller( artifactSchema )
53
+ puller = new GenericArtifactPuller( path.dirname( options.filePath ), artifactSchema )
49
54
  break
50
55
  default:
51
56
  warning( `Unsupported artifact type "${artifactSchema.type}"` )
@@ -69,6 +74,8 @@ async function main() {
69
74
  counter = pullResults.filter( Boolean ).length
70
75
 
71
76
  log( `Successfully pulled ${counter}/${filteredArtifactsSchema.length} artifacts` )
77
+
78
+ return counter === filteredArtifactsSchema.length
72
79
  }
73
80
 
74
81
  main()
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import path from 'node:path'
4
+
3
5
  import { program } from 'commander'
4
6
 
5
7
  import { warning, log } from '../Utils.mjs'
@@ -11,31 +13,33 @@ import { GenericArtifactPusher } from './ArtifactPusher.mjs'
11
13
  program
12
14
  .name( 'push-artifact' )
13
15
  .description( 'Push artifacts to GCP Artifact Registry' )
14
- .requiredOption( '-f, --file-path <path>', 'Path to artifacts.yaml file' )
15
- .option( '-n, --package-name <name>', 'Filter by artifact package name' )
16
- .option( '-v, --package-version <version>', 'Filter by artifact package version' )
16
+ .option( '-f, --file-path <file-path>', 'Path to artifacts.yaml file', './artifacts.yaml' )
17
+ .option( '-a, --artifact-path <artifact-path>', 'GCP artifact path' )
17
18
  .parse()
18
19
 
19
20
  const options = program.opts()
20
21
 
21
22
  async function main() {
22
- const artifactsYmlPath = options.filePath
23
- const artifactPackageName = options.packageName || null
24
- const artifactPackageVersion = options.packageVersion || null
25
-
26
- const artifactsSchema = await getArtifactsSchema( artifactsYmlPath )
23
+ const artifactsSchema = await getArtifactsSchema( options.filePath )
27
24
  const flattenedArtifactsSchema = flattenArtifactsSchema( artifactsSchema )
28
-
29
- let filteredArtifactsSchema
30
- if ( artifactPackageName && artifactPackageVersion ) {
31
- filteredArtifactsSchema = flattenedArtifactsSchema.filter(
32
- artifact => artifact.gcp_artifact_registry_package_name === artifactPackageName &&
33
- artifact.gcp_artifact_registry_package_version === artifactPackageVersion )
34
- } else if ( artifactPackageName ) {
25
+
26
+ let filteredArtifactsSchema = flattenedArtifactsSchema
27
+
28
+ if ( options.artifactPath ) {
29
+ const artifactPathElements = options.artifactPath.split( '/' )
30
+ if ( artifactPathElements.length !== 5 ) {
31
+ warning( 'Invalid artifact path, it must be in the format <project>/<location>/<repository>/<package>/<version>' )
32
+ return false
33
+ }
34
+
35
+ const [ project, location, repository, packageName, version ] = artifactPathElements
36
+
35
37
  filteredArtifactsSchema = flattenedArtifactsSchema.filter(
36
- artifact => artifact.gcp_artifact_registry_package_name === artifactPackageName )
37
- } else {
38
- filteredArtifactsSchema = flattenedArtifactsSchema
38
+ artifact => artifact.project === project &&
39
+ artifact.location === location &&
40
+ artifact.repository === repository &&
41
+ artifact.package === packageName &&
42
+ artifact.version === version )
39
43
  }
40
44
 
41
45
  let counter = 0
@@ -45,7 +49,7 @@ async function main() {
45
49
  let pusher
46
50
  switch ( artifactSchema.type ) {
47
51
  case ArtifactTypeEnum.GENERIC:
48
- pusher = new GenericArtifactPusher( artifactSchema )
52
+ pusher = new GenericArtifactPusher( path.dirname( options.filePath ), artifactSchema )
49
53
  break
50
54
  default:
51
55
  warning( `Unsupported artifact type "${artifactSchema.type}"` )
@@ -69,6 +73,8 @@ async function main() {
69
73
  counter = pushResults.filter( Boolean ).length
70
74
 
71
75
  log( `Successfully pushed ${counter}/${filteredArtifactsSchema.length} artifacts` )
76
+
77
+ return counter === filteredArtifactsSchema.length
72
78
  }
73
79
 
74
80
  main()