@google-cloud/cloud-run-mcp 1.9.0 → 1.11.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 (50) hide show
  1. package/README.md +24 -0
  2. package/SECURITY.md +7 -0
  3. package/cloudbuild.yaml +37 -0
  4. package/example-sources-to-deploy/java/pom.xml +41 -0
  5. package/example-sources-to-deploy/java/src/main/java/com/example/helloworld/HelloworldApplication.java +21 -0
  6. package/example-sources-to-deploy/java/src/main/resources/application.properties +1 -0
  7. package/example-sources-to-deploy/nodejs/index.js +15 -0
  8. package/example-sources-to-deploy/nodejs/package.json +12 -0
  9. package/example-sources-to-deploy/python/pip-project/main.py +11 -0
  10. package/example-sources-to-deploy/python/pip-project/requirements.txt +2 -0
  11. package/example-sources-to-deploy/python/pyproject-project/main.py +11 -0
  12. package/example-sources-to-deploy/python/pyproject-project/pyproject.toml +10 -0
  13. package/gemini-extension.json +12 -0
  14. package/lib/clients.js +104 -3
  15. package/lib/cloud-api/build.js +192 -28
  16. package/lib/cloud-api/run.js +62 -0
  17. package/lib/cloud-api/secrets.js +196 -0
  18. package/lib/cloud-api/storage.js +164 -16
  19. package/lib/deployment/compose.js +517 -0
  20. package/lib/deployment/constants.js +11 -0
  21. package/lib/deployment/deployer.js +334 -30
  22. package/lib/deployment/helpers.js +99 -49
  23. package/lib/deployment/source-processor.js +0 -48
  24. package/lib/deployment/universal-maker.js +147 -0
  25. package/lib/util/archive.js +3 -3
  26. package/lib/util/artifacts.js +189 -0
  27. package/lib/util/helpers.js +109 -0
  28. package/mcp-server.js +4 -0
  29. package/package.json +6 -3
  30. package/skills/cloud-run/SKILL.md +109 -0
  31. package/test/local/archive.test.js +88 -0
  32. package/test/local/clients.test.js +224 -2
  33. package/test/local/cloud-api/build.test.js +330 -25
  34. package/test/local/cloud-api/run.test.js +129 -0
  35. package/test/local/cloud-api/secrets.test.js +291 -0
  36. package/test/local/cloud-api/storage.test.js +405 -0
  37. package/test/local/compose.test.js +618 -0
  38. package/test/local/deploy.test.js +408 -0
  39. package/test/local/deployment-helpers.test.js +126 -79
  40. package/test/local/source-processor.test.js +0 -165
  41. package/test/local/test-utils.js +2 -18
  42. package/test/local/tools.test.js +43 -1
  43. package/test/local/universal-maker.test.js +157 -0
  44. package/test/local/util.test.js +361 -0
  45. package/test/need-gcp/deployer.test.js +1 -1
  46. package/test/need-gcp/workflows/deployment-workflows.test.js +172 -6
  47. package/tools/register-tools.js +19 -3
  48. /package/example-sources-to-deploy/{Dockerfile → golang/Dockerfile} +0 -0
  49. /package/example-sources-to-deploy/{go.mod → golang/go.mod} +0 -0
  50. /package/example-sources-to-deploy/{main.go → golang/main.go} +0 -0
@@ -15,8 +15,9 @@ limitations under the License.
15
15
  */
16
16
 
17
17
  import path from 'path';
18
- import fs from 'fs';
18
+ import fs, { promises as fsPromises } from 'fs';
19
19
  import { DEPLOYMENT_CONFIG, RUNTIMES } from './constants.js';
20
+ import { uploadToStorageBucket } from '../cloud-api/storage.js';
20
21
 
21
22
  /**
22
23
  * Checks if the input is a single folder path.
@@ -61,6 +62,30 @@ function checkIfDockerFileExists(files) {
61
62
  return false;
62
63
  }
63
64
 
65
+ /**
66
+ * Checks if a compose file exists in the provided files array.
67
+ * @param {Array<string|Object>} files - An array of file paths or objects with filename properties.
68
+ * @returns {string|null} - Returns the path/filename of the compose file if found, null otherwise.
69
+ */
70
+ function checkIfComposeFileExists(files) {
71
+ const composeFiles = [
72
+ 'compose.yaml',
73
+ 'compose.yml',
74
+ 'docker-compose.yaml',
75
+ 'docker-compose.yml',
76
+ ];
77
+ if (isFolder(files)) {
78
+ for (const name of composeFiles) {
79
+ const fullPath = path.join(files[0], name);
80
+ if (fs.existsSync(fullPath)) {
81
+ return fullPath;
82
+ }
83
+ }
84
+ }
85
+ // TODO: we do not handle file content deployment at present as run-compose binary does not support it
86
+ return null;
87
+ }
88
+
64
89
  /**
65
90
  * Checks if the provided folder appears to be a Node.js project.
66
91
  * It looks for a `package.json` file in the root of the first path in the `files` array.
@@ -72,6 +97,18 @@ function checkIfNodeJsRuntime(files) {
72
97
  return fs.existsSync(packageJsonPath);
73
98
  }
74
99
 
100
+ /**
101
+ * Checks if the provided folder appears to be a Python project.
102
+ * It looks for a `requirements.txt` or `pyproject.toml` file in the root of the first path in the `files` array.
103
+ * @param {string[]} files - Array of file paths
104
+ * @returns {boolean}
105
+ */
106
+ function checkIfPythonRuntime(files) {
107
+ const requirementsTxtPath = path.join(files[0], 'requirements.txt');
108
+ const pyprojectTomlPath = path.join(files[0], 'pyproject.toml');
109
+ return fs.existsSync(requirementsTxtPath) || fs.existsSync(pyprojectTomlPath);
110
+ }
111
+
75
112
  /**
76
113
  * Returns an empty deployment attributes object.
77
114
  * This is used as a default or when attributes cannot be determined.
@@ -86,55 +123,30 @@ function getEmptyDeploymentAttrs() {
86
123
  };
87
124
  }
88
125
 
89
- /**
90
- * Extracts deployment attributes (runtime, command, args, baseImage) from a Node.js project's `package.json`.
91
- * This function is used for direct source deployments, bypassing Cloud Build.
92
- * It returns empty attributes if a `start` script is missing, invalid, or if `engines.node` is specified (indicating a need for Cloud Build).
93
- * @param {string[]} files - Array of file paths
94
- * @returns {{runtime: string|undefined, cmd: string[]|undefined, args: string[]|undefined, baseImage: string|undefined}} Deployment attributes object.
95
- */
96
- function getNodeJsDeploymentAttrs(files) {
97
- const packageJsonPath = path.join(files[0], 'package.json');
98
- let packageJson;
99
-
100
- try {
101
- packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
102
- } catch (error) {
103
- // If package.json exists but is invalid, we can't extract attributes.
104
- return getEmptyDeploymentAttrs();
105
- }
106
-
107
- const startScript = packageJson?.scripts?.start;
108
- const hasNodeEngine = packageJson?.engines?.node;
109
-
110
- if (typeof startScript !== 'string' || !startScript.trim() || hasNodeEngine) {
111
- return getEmptyDeploymentAttrs();
112
- }
113
-
114
- // Handle multiple spaces properly
115
- const parts = startScript.trim().split(/\s+/);
116
-
117
- return {
118
- runtime: RUNTIMES.NODEJS,
119
- cmd: [parts[0]],
120
- args: parts.slice(1),
121
- baseImage: DEPLOYMENT_CONFIG.DEFAULT_NODE_BASE_IMAGE, // latest default base image
122
- };
123
- }
124
-
125
126
  /**
126
127
  * Determines deployment attributes based on the file list/folder content.
127
128
  * @param {Array<string|Object>} files - Array of file paths or file objects.
128
- * @returns {{runtime: string|undefined, cmd: string[]|undefined, args: string[]|undefined, baseImage: string|undefined}} Deployment attributes object.
129
+ * @returns {{runtime: string|undefined, baseImage: string|undefined}} Deployment attributes object.
129
130
  */
130
131
  function getDeploymentAttrs(files) {
132
+ let deploymentAttrs = getEmptyDeploymentAttrs();
131
133
  // Currently only support detection of NodeJs runtime
132
- if (!isFolder(files) || !checkIfNodeJsRuntime(files)) {
134
+ if (
135
+ !isFolder(files) ||
136
+ !(checkIfNodeJsRuntime(files) || checkIfPythonRuntime(files))
137
+ ) {
133
138
  // TODO: support file list deployment runtime detection
134
- return getEmptyDeploymentAttrs();
139
+ return deploymentAttrs;
135
140
  }
136
141
 
137
- return getNodeJsDeploymentAttrs(files);
142
+ if (checkIfPythonRuntime(files)) {
143
+ deploymentAttrs.runtime = RUNTIMES.PYTHON;
144
+ deploymentAttrs.baseImage = DEPLOYMENT_CONFIG.DEFAULT_PYTHON_BASE_IMAGE;
145
+ } else {
146
+ deploymentAttrs.runtime = RUNTIMES.NODEJS;
147
+ deploymentAttrs.baseImage = DEPLOYMENT_CONFIG.DEFAULT_NODE_BASE_IMAGE;
148
+ }
149
+ return deploymentAttrs;
138
150
  }
139
151
 
140
152
  /**
@@ -145,6 +157,7 @@ function getDeploymentAttrs(files) {
145
157
  export function makeFileDeploymentMetadata(files) {
146
158
  return {
147
159
  hasDockerfile: checkIfDockerFileExists(files),
160
+ composeFilePath: checkIfComposeFileExists(files),
148
161
  deploymentAttrs: getDeploymentAttrs(files),
149
162
  };
150
163
  }
@@ -157,13 +170,7 @@ export function makeFileDeploymentMetadata(files) {
157
170
  * @returns {boolean} True if zip deployment is feasible.
158
171
  */
159
172
  export function canDeployWithoutBuild({ hasDockerfile, deploymentAttrs }) {
160
- return !!(
161
- !hasDockerfile &&
162
- deploymentAttrs &&
163
- deploymentAttrs.runtime &&
164
- deploymentAttrs.cmd &&
165
- deploymentAttrs.args
166
- );
173
+ return !!(!hasDockerfile && deploymentAttrs && deploymentAttrs.runtime);
167
174
  }
168
175
 
169
176
  /**
@@ -179,7 +186,7 @@ export function createDirectSourceDeploymentContainer({
179
186
  fileName,
180
187
  deploymentAttrs,
181
188
  }) {
182
- return {
189
+ const container = {
183
190
  image: DEPLOYMENT_CONFIG.NO_BUILD_IMAGE_TYPE,
184
191
  baseImageUri: deploymentAttrs.baseImage,
185
192
  sourceCode: {
@@ -191,4 +198,47 @@ export function createDirectSourceDeploymentContainer({
191
198
  command: deploymentAttrs.cmd,
192
199
  args: deploymentAttrs.args,
193
200
  };
201
+
202
+ // Add environment variables if provided
203
+ if (deploymentAttrs.envVars && typeof deploymentAttrs.envVars === 'object') {
204
+ container.env = Object.entries(deploymentAttrs.envVars).map(
205
+ ([name, value]) => ({
206
+ name,
207
+ value: String(value),
208
+ })
209
+ );
210
+ }
211
+
212
+ return container;
213
+ }
214
+
215
+ /**
216
+ * Uploads a local directory to a GCS bucket recursively.
217
+ * @param {*} bucket - GCS bucket object
218
+ * @param {string} localPath - Local directory path
219
+ * @param {string} gcsPrefix - GCS prefix (blob name starting part)
220
+ * @param {function} progressCallback - Callback for progress
221
+ */
222
+ export async function uploadDirectory(
223
+ bucket,
224
+ localPath,
225
+ gcsPrefix,
226
+ progressCallback
227
+ ) {
228
+ const entries = await fsPromises.readdir(localPath, { withFileTypes: true });
229
+ for (const entry of entries) {
230
+ const fullPath = path.join(localPath, entry.name);
231
+ const destination = `${gcsPrefix}/${entry.name}`;
232
+ if (entry.isDirectory()) {
233
+ await uploadDirectory(bucket, fullPath, destination, progressCallback);
234
+ } else {
235
+ const buffer = await fsPromises.readFile(fullPath);
236
+ await uploadToStorageBucket(
237
+ bucket,
238
+ buffer,
239
+ destination,
240
+ progressCallback
241
+ );
242
+ }
243
+ }
194
244
  }
@@ -58,54 +58,6 @@ export async function prepareSourceDirectory(files, progressCallback) {
58
58
  return tempDir;
59
59
  }
60
60
 
61
- /**
62
- * Installs Node.js dependencies in the target directory if a package.json exists.
63
- * Skips if node_modules already exists (assumed pre-installed or copied).
64
- *
65
- * @param {string} targetDir - The directory containing the source code.
66
- */
67
- async function installNodeDependencies(targetDir) {
68
- const nodeModulesPath = path.join(targetDir, 'node_modules');
69
-
70
- if (fs.existsSync(nodeModulesPath)) {
71
- console.log(
72
- `Existing node_modules detected. Skipping dependency installation.`
73
- );
74
- return;
75
- }
76
-
77
- // Check for lock file to decide between install and ci
78
- const hasLockFile = fs.existsSync(path.join(targetDir, 'package-lock.json'));
79
- const npmCommand = hasLockFile
80
- ? 'npm ci --omit=dev'
81
- : 'npm install --omit=dev';
82
-
83
- try {
84
- await execAsync(npmCommand, { cwd: targetDir });
85
- console.log(`${npmCommand} completed successfully`);
86
- } catch (e) {
87
- console.log(`Dependency installation failed: ${e.message}`);
88
- }
89
- }
90
-
91
- /**
92
- * Generic wrapper to install dependencies for the detected runtime.
93
- *
94
- * @param {string} targetDir - The directory containing the source code.
95
- * @param {Object} deploymentAttrs - Deployment attributes containing runtime info.
96
- */
97
- export async function installDependencies(targetDir, deploymentAttrs) {
98
- switch (deploymentAttrs?.runtime) {
99
- case RUNTIMES.NODEJS:
100
- await installNodeDependencies(targetDir);
101
- break;
102
- default:
103
- console.log(
104
- `No specific dependency installation logic for runtime: ${deploymentAttrs?.runtime}`
105
- );
106
- }
107
- }
108
-
109
61
  /**
110
62
  * Cleans up the temporary directory.
111
63
  *
@@ -0,0 +1,147 @@
1
+ /*
2
+ Copyright 2025 Google LLC
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ https://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import { exec } from 'child_process';
18
+ import fs from 'fs';
19
+ import path from 'path';
20
+ import os from 'os';
21
+ import util from 'util';
22
+ import { logAndProgress } from '../util/helpers.js';
23
+ import { TEMP_PATHS } from './constants.js';
24
+ import { ensureRepositoryDownloaded } from '../util/artifacts.js';
25
+
26
+ const execAsync = util.promisify(exec);
27
+
28
+ const UNIVERSAL_MAKER_BIN = 'universal_maker';
29
+ const UM_VERSION = '1.0.0';
30
+
31
+ const AR_PROJECT = 'serverless-runtimes';
32
+ const AR_LOCATION = 'us-central1';
33
+ const AR_REPOSITORY = 'universal-maker';
34
+
35
+ const ARCH_MAPPING = {
36
+ linux_x64: 'x86-64',
37
+ darwin_arm64: 'darwin-arm64',
38
+ };
39
+
40
+ /**
41
+ * Gets the architecture key for the current platform.
42
+ * @returns {string|null} The architecture key or null if not supported.
43
+ */
44
+ function getUniversalMakerArchitectureKey() {
45
+ const platform = process.platform;
46
+ const arch = process.arch;
47
+
48
+ if (platform === 'linux' && arch === 'x64') {
49
+ return 'linux_x64';
50
+ } else if (platform === 'darwin' && arch === 'arm64') {
51
+ return 'darwin_arm64';
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Runs Universal Maker on the given application directory.
58
+ * @param {string} appDir - Directory containing the application source.
59
+ * @param {function} progressCallback - Progress callback.
60
+ * @param {string} accessToken - Access token for authentication.
61
+ * @returns {Promise<object|null>} The parsed build_output.json or null.
62
+ */
63
+ export async function runUniversalMaker(appDir, accessToken, progressCallback) {
64
+ const binDir = path.join(
65
+ os.homedir(),
66
+ TEMP_PATHS.BASE,
67
+ TEMP_PATHS.BIN_SUBDIR
68
+ );
69
+ const key = getUniversalMakerArchitectureKey();
70
+ if (!key) {
71
+ await logAndProgress(
72
+ `Universal Maker is not supported on ${process.platform} ${process.arch}.`,
73
+ progressCallback,
74
+ 'debug'
75
+ );
76
+ return null;
77
+ }
78
+
79
+ const arch = ARCH_MAPPING[key];
80
+ const binPath = path.join(binDir, UNIVERSAL_MAKER_BIN);
81
+
82
+ const binPathResult = await ensureRepositoryDownloaded(
83
+ binPath,
84
+ {
85
+ project: AR_PROJECT,
86
+ location: AR_LOCATION,
87
+ repository: AR_REPOSITORY,
88
+ artifactPath: `${arch}:${UM_VERSION}:${UNIVERSAL_MAKER_BIN}`,
89
+ displayName: 'Universal Maker',
90
+ },
91
+ accessToken,
92
+ progressCallback
93
+ );
94
+
95
+ if (!binPathResult) {
96
+ return null;
97
+ }
98
+
99
+ const outputDir = path.join(os.tmpdir(), `um-output-${Date.now()}`);
100
+ if (!fs.existsSync(outputDir)) {
101
+ fs.mkdirSync(outputDir, { recursive: true });
102
+ }
103
+
104
+ try {
105
+ // ./universal_maker -application_dir <source_code_path> -output_dir <output_results_path> -output_format json
106
+ await logAndProgress(
107
+ `Running Universal Maker: ${binPath} -application_dir ${appDir} -output_dir ${outputDir} -output_format json`,
108
+ progressCallback,
109
+ 'debug'
110
+ );
111
+ const command = `"${binPath}" -application_dir "${appDir}" -output_dir "${outputDir}" -output_format json`;
112
+ await execAsync(command);
113
+
114
+ const outputPath = path.join(outputDir, 'build_output.json');
115
+ if (fs.existsSync(outputPath)) {
116
+ const output = JSON.parse(fs.readFileSync(outputPath, 'utf-8'));
117
+ await logAndProgress(
118
+ 'Universal Maker completed successfully.',
119
+ progressCallback
120
+ );
121
+ return output;
122
+ } else {
123
+ await logAndProgress(
124
+ 'Universal Maker did not produce build_output.json',
125
+ progressCallback,
126
+ 'debug'
127
+ );
128
+ return null;
129
+ }
130
+ } catch (error) {
131
+ await logAndProgress(
132
+ `Universal Maker failed: ${error.message}`,
133
+ progressCallback,
134
+ 'debug'
135
+ );
136
+ return null;
137
+ } finally {
138
+ // Cleanup output dir
139
+ try {
140
+ if (fs.existsSync(outputDir)) {
141
+ fs.rmSync(outputDir, { recursive: true, force: true });
142
+ }
143
+ } catch (e) {
144
+ // ignore cleanup errors
145
+ }
146
+ }
147
+ }
@@ -31,7 +31,7 @@ import { logAndProgress } from './helpers.js';
31
31
  export async function zipFiles(files, useTarGz, progressCallback) {
32
32
  const path = await import('path');
33
33
  const fs = await import('fs');
34
- const archiver = (await import('archiver')).default;
34
+ const { TarArchive, ZipArchive } = await import('archiver');
35
35
 
36
36
  return new Promise((resolve, reject) => {
37
37
  logAndProgress('Creating in-memory archive...', progressCallback);
@@ -39,11 +39,11 @@ export async function zipFiles(files, useTarGz, progressCallback) {
39
39
 
40
40
  let archive;
41
41
  if (useTarGz) {
42
- archive = archiver('tar', {
42
+ archive = new TarArchive({
43
43
  gzip: { level: 9 },
44
44
  });
45
45
  } else {
46
- archive = archiver('zip', {
46
+ archive = new ZipArchive({
47
47
  zlib: { level: 9 },
48
48
  });
49
49
  }
@@ -0,0 +1,189 @@
1
+ /*
2
+ Copyright 2025 Google LLC
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ https://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */
16
+
17
+ import fs from 'fs';
18
+ import path from 'path';
19
+ import crypto from 'crypto';
20
+ import { logAndProgress } from './helpers.js';
21
+ import { getArtifactRegistryClient } from '../clients.js';
22
+
23
+ const AR_HOST = 'https://artifactregistry.googleapis.com';
24
+ const AR_DOWNLOAD_PATH_PREFIX = '/download/v1/';
25
+ const AR_DOWNLOAD_URL_SUFFIX = ':download?alt=media';
26
+
27
+ /**
28
+ * Checks if the existing binary is up to date by comparing its SHA256 hash with the remote metadata.
29
+ * If the binary is out of date, it removes it.
30
+ * @param {string} binPath - Path to the local binary.
31
+ * @param {object} artifactParams - Parameters for the remote artifact.
32
+ * @param {string} artifactParams.project - Artifact Registry project.
33
+ * @param {string} artifactParams.location - Artifact Registry location.
34
+ * @param {string} artifactParams.repository - Artifact Registry repository.
35
+ * @param {string} artifactParams.artifactPath - Path to the artifact within the repository.
36
+ * @param {string} artifactParams.displayName - A friendly name for logging.
37
+ * @param {string} accessToken - Access token for authentication.
38
+ * @param {function} progressCallback - Progress callback.
39
+ * @returns {Promise<boolean>} True if the binary is up to date, false otherwise.
40
+ */
41
+ export async function isBinaryUpToDate(
42
+ binPath,
43
+ artifactParams,
44
+ accessToken,
45
+ progressCallback
46
+ ) {
47
+ const { project, location, repository, artifactPath, displayName } =
48
+ artifactParams;
49
+
50
+ if (!fs.existsSync(binPath)) {
51
+ return false;
52
+ }
53
+
54
+ try {
55
+ await logAndProgress(
56
+ `Checking for ${displayName} updates...`,
57
+ progressCallback,
58
+ 'debug'
59
+ );
60
+
61
+ const artifactRegistryClient = await getArtifactRegistryClient(
62
+ project,
63
+ accessToken
64
+ );
65
+ const resourceName = artifactRegistryClient.filePath(
66
+ project,
67
+ location,
68
+ repository,
69
+ artifactPath
70
+ );
71
+
72
+ const [file] = await artifactRegistryClient.getFile({ name: resourceName });
73
+ const remoteSha256Base64 = file.hashes?.find(
74
+ (h) => h.type === 'SHA256'
75
+ )?.value;
76
+
77
+ if (!remoteSha256Base64) {
78
+ return false; // Cannot verify, download new binary
79
+ }
80
+
81
+ const remoteSha256Hex = Buffer.from(remoteSha256Base64, 'base64').toString(
82
+ 'hex'
83
+ );
84
+ const localFileContent = fs.readFileSync(binPath);
85
+ const localSha256Hex = crypto
86
+ .createHash('sha256')
87
+ .update(localFileContent)
88
+ .digest('hex');
89
+
90
+ if (remoteSha256Hex === localSha256Hex) {
91
+ await logAndProgress(
92
+ `${displayName} binary is up to date.`,
93
+ progressCallback,
94
+ 'debug'
95
+ );
96
+ return true;
97
+ }
98
+
99
+ await logAndProgress(
100
+ `${displayName} binary is out of date. Removing existing binary.`,
101
+ progressCallback,
102
+ 'debug'
103
+ );
104
+ fs.unlinkSync(binPath);
105
+ return false;
106
+ } catch (error) {
107
+ await logAndProgress(
108
+ `Could not check for ${displayName} updates: ${error.message}. Downloading the new binary.`,
109
+ progressCallback,
110
+ 'debug'
111
+ );
112
+ return false; // On error, fallback to downloading the new binary
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Downloads a binary from Artifact Registry if it doesn't exist or is out of date.
118
+ * @param {string} binPath - Path where the binary should be stored.
119
+ * @param {object} artifactParams - Parameters for the remote artifact.
120
+ * @param {string} artifactParams.project - Artifact Registry project.
121
+ * @param {string} artifactParams.location - Artifact Registry location.
122
+ * @param {string} artifactParams.repository - Artifact Registry repository.
123
+ * @param {string} artifactParams.artifactPath - Path to the artifact within the repository.
124
+ * @param {string} artifactParams.displayName - A friendly name for logging.
125
+ * @param {string} accessToken - Access token for authentication.
126
+ * @param {function} progressCallback - Progress callback.
127
+ * @returns {Promise<string|null>} Path to the binary or null if it fails.
128
+ */
129
+ export async function ensureRepositoryDownloaded(
130
+ binPath,
131
+ artifactParams,
132
+ accessToken,
133
+ progressCallback
134
+ ) {
135
+ const { project, location, repository, artifactPath, displayName } =
136
+ artifactParams;
137
+ const binDir = path.dirname(binPath);
138
+
139
+ if (
140
+ await isBinaryUpToDate(
141
+ binPath,
142
+ artifactParams,
143
+ accessToken,
144
+ progressCallback
145
+ )
146
+ ) {
147
+ return binPath;
148
+ }
149
+
150
+ await logAndProgress(`Downloading ${displayName}...`, progressCallback);
151
+
152
+ try {
153
+ if (!fs.existsSync(binDir)) {
154
+ fs.mkdirSync(binDir, { recursive: true });
155
+ }
156
+
157
+ const artifactRegistryClient = await getArtifactRegistryClient(
158
+ project,
159
+ accessToken
160
+ );
161
+ const resourceName = artifactRegistryClient.filePath(
162
+ project,
163
+ location,
164
+ repository,
165
+ artifactPath
166
+ );
167
+
168
+ const url = `${AR_HOST}${AR_DOWNLOAD_PATH_PREFIX}${resourceName}${AR_DOWNLOAD_URL_SUFFIX}`;
169
+ const response = await artifactRegistryClient.auth.request({
170
+ url,
171
+ responseType: 'stream',
172
+ });
173
+
174
+ const dest = fs.createWriteStream(binPath);
175
+ await new Promise((resolve, reject) => {
176
+ response.data.pipe(dest).on('finish', resolve).on('error', reject);
177
+ });
178
+
179
+ fs.chmodSync(binPath, '755');
180
+ return binPath;
181
+ } catch (error) {
182
+ await logAndProgress(
183
+ `Failed to download ${displayName}: ${error.message}`,
184
+ progressCallback,
185
+ 'debug'
186
+ );
187
+ return null;
188
+ }
189
+ }