@ibm-cloud/cd-tools 1.10.0 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Licensed Materials - Property of IBM
3
- * (c) Copyright IBM Corporation 2025. All Rights Reserved.
3
+ * (c) Copyright IBM Corporation 2025, 2026. All Rights Reserved.
4
4
  *
5
5
  * Note to U.S. Government Users Restricted Rights:
6
6
  * Use, duplication or disclosure restricted by GSA ADP Schedule
@@ -19,7 +19,7 @@ import fs from 'node:fs';
19
19
 
20
20
  import { Command, Option } from 'commander';
21
21
 
22
- import { parseEnvVar } from './utils/utils.js';
22
+ import { parseEnvVar, promptUserConfirmation } from './utils/utils.js';
23
23
  import { logger, LOG_STAGES } from './utils/logger.js';
24
24
  import { setTerraformEnv, initProviderFile, setupTerraformFiles, runTerraformInit, getNumResourcesPlanned, runTerraformApply, getNumResourcesCreated, getNewToolchainId } from './utils/terraform.js';
25
25
  import { getAccountId, getBearerToken, getCdInstanceByRegion, getResourceGroups, getToolchain } from './utils/requests.js';
@@ -110,7 +110,11 @@ async function main(options) {
110
110
  const accountId = await getAccountId(bearer, apiKey);
111
111
 
112
112
  // check for continuous delivery instance in target region
113
- if (!await getCdInstanceByRegion(bearer, accountId, targetRegion)) throw Error(`Could not find a Continuous Delivery instance in the target region '${targetRegion}', please create one before proceeding.`);
113
+ if (!await getCdInstanceByRegion(bearer, accountId, targetRegion)) {
114
+ // give users the option to bypass
115
+ logger.warn(`Warning! Could not find a Continuous Delivery instance in the target region '${targetRegion}' or you do not have permission to view, please create one before proceeding if one does not exist already.`, LOG_STAGES.setup);
116
+ await promptUserConfirmation(`Do you want to proceed anyway?`, 'yes', 'Toolchain migration cancelled.');
117
+ }
114
118
 
115
119
  // check for existing .tf files in output directory
116
120
  if (fs.existsSync(outputDir)) {
@@ -11,20 +11,24 @@ import { Command } from 'commander';
11
11
  import axios from 'axios';
12
12
  import readline from 'readline/promises';
13
13
  import { writeFile } from 'fs/promises';
14
- import { TARGET_REGIONS, SOURCE_REGIONS } from '../config.js';
14
+ import { SOURCE_REGIONS } from '../config.js';
15
15
  import { getWithRetry } from './utils/requests.js';
16
16
 
17
17
  const HTTP_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes default
18
18
 
19
19
  class GitLabClient {
20
20
  constructor(baseURL, token) {
21
+ const root = baseURL.endsWith("/") ? baseURL : `${baseURL}/`;
22
+
21
23
  this.client = axios.create({
22
- baseURL: baseURL.endsWith('/') ? `${baseURL}api/v4` : `${baseURL}/api/v4`,
24
+ baseURL: `${root}api/v4`,
23
25
  timeout: HTTP_TIMEOUT_MS,
24
- headers: {
25
- 'Authorization': `Bearer ${token}`,
26
- 'Content-Type': 'application/json'
27
- }
26
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
27
+ });
28
+ this.graph = axios.create({
29
+ baseURL: `${root}api`,
30
+ timeout: HTTP_TIMEOUT_MS,
31
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
28
32
  });
29
33
  }
30
34
 
@@ -110,6 +114,50 @@ class GitLabClient {
110
114
  return projects;
111
115
  }
112
116
 
117
+ async listGroupProjectsGraphQL(groupFullPath, { includeSubgroups = true, pageSize = 200, maxProjects = 5000 } = {}) {
118
+ const out = [];
119
+ let after = null;
120
+
121
+ const query = `
122
+ query($fullPath: ID!, $after: String, $includeSubgroups: Boolean!, $pageSize: Int!) {
123
+ group(fullPath: $fullPath) {
124
+ projects(includeSubgroups: $includeSubgroups, first: $pageSize, after: $after) {
125
+ nodes {
126
+ fullPath
127
+ nameWithNamespace
128
+ httpUrlToRepo
129
+ }
130
+ pageInfo {
131
+ hasNextPage
132
+ endCursor
133
+ }
134
+ }
135
+ }
136
+ }
137
+ `;
138
+
139
+ while (out.length < maxProjects) {
140
+ const resp = await this.graph.post("/graphql", {
141
+ query,
142
+ variables: { fullPath: groupFullPath, after, includeSubgroups, pageSize },
143
+ });
144
+
145
+ if (resp.data?.errors?.length) {
146
+ throw new Error(`GraphQL errors: ${JSON.stringify(resp.data.errors)}`);
147
+ }
148
+
149
+ const projects = resp.data?.data?.group?.projects?.nodes || [];
150
+ const pageInfo = resp.data?.data?.group?.projects?.pageInfo;
151
+
152
+ out.push(...projects);
153
+
154
+ if (!pageInfo?.hasNextPage) break;
155
+ after = pageInfo.endCursor;
156
+ }
157
+
158
+ return out;
159
+ }
160
+
113
161
  async getGroup(groupId) {
114
162
  const response = await this.client.get(`/groups/${groupId}`);
115
163
  return response.data;
@@ -186,26 +234,26 @@ class GitLabClient {
186
234
  }
187
235
  }
188
236
 
189
- async getBulkImportEntitiesAll(importId, { perPage = 100, maxPages = 200 } = {}) {
190
- const all = [];
191
- let page = 1;
237
+ async getBulkImportEntitiesAll(importId, { perPage = 100, maxPages = 200 } = {}) {
238
+ const all = [];
239
+ let page = 1;
192
240
 
193
- while (page <= maxPages) {
194
- const resp = await getWithRetry(
195
- this.client,
196
- `/bulk_imports/${importId}/entities`,
197
- { page, per_page: perPage }
198
- );
241
+ while (page <= maxPages) {
242
+ const resp = await getWithRetry(
243
+ this.client,
244
+ `/bulk_imports/${importId}/entities`,
245
+ { page, per_page: perPage }
246
+ );
199
247
 
200
- all.push(...(resp.data || []));
248
+ all.push(...(resp.data || []));
201
249
 
202
- const nextPage = Number(resp.headers?.['x-next-page'] || 0);
203
- if (!nextPage) break;
250
+ const nextPage = Number(resp.headers?.['x-next-page'] || 0);
251
+ if (!nextPage) break;
204
252
 
205
- page = nextPage;
206
- }
253
+ page = nextPage;
254
+ }
207
255
 
208
- return all;
256
+ return all;
209
257
  }
210
258
 
211
259
  async getGroupByFullPath(fullPath) {
@@ -250,23 +298,19 @@ function validateAndConvertRegion(region) {
250
298
  }
251
299
 
252
300
  // Build a mapping of: old http_url_to_repo -> new http_url_to_repo
253
- async function generateUrlMappingFile({sourceUrl, destUrl, sourceGroup, destinationGroupPath, sourceProjects}) {
301
+ async function generateUrlMappingFile({ destUrl, sourceGroup, destinationGroupPath, sourceProjects }) {
254
302
  const destBase = destUrl.endsWith('/') ? destUrl.slice(0, -1) : destUrl;
255
303
  const urlMapping = {};
256
304
 
257
- const groupPrefix = `${sourceGroup.full_path}/`;
258
-
259
305
  for (const project of sourceProjects) {
260
- const oldRepoUrl = project.http_url_to_repo; // ends with .git
306
+ const oldRepoUrl = project.http_url_to_repo || project.httpUrlToRepo;
261
307
 
262
- // path_with_namespace is like "group/subgroup/project-1"
263
- let relativePath;
264
- if (project.path_with_namespace.startsWith(groupPrefix)) {
265
- relativePath = project.path_with_namespace.slice(groupPrefix.length);
266
- } else {
267
- // Fallback if for some reason full_path is not a prefix
268
- relativePath = project.path_with_namespace;
269
- }
308
+ const fullPath = project.path_with_namespace || project.fullPath || "";
309
+ const groupPrefix = `${sourceGroup.full_path}/`;
310
+
311
+ const relativePath = fullPath.startsWith(groupPrefix)
312
+ ? fullPath.slice(groupPrefix.length)
313
+ : fullPath;
270
314
 
271
315
  const newRepoUrl = `${destBase}/${destinationGroupPath}/${relativePath}.git`;
272
316
  urlMapping[oldRepoUrl] = newRepoUrl;
@@ -387,7 +431,7 @@ function isGroupEntity(e) {
387
431
  return e?.source_type === 'group_entity' || e?.entity_type === 'group_entity' || e?.entity_type === 'group';
388
432
  }
389
433
 
390
- async function handleBulkImportConflict({destination, destUrl, sourceGroupFullPath, destinationGroupPath, importResErr}) {
434
+ async function handleBulkImportConflict({ destination, destUrl, sourceGroupFullPath, destinationGroupPath, importResErr }) {
391
435
  const historyUrl = buildGroupImportHistoryUrl(destUrl);
392
436
  const groupUrl = buildGroupUrl(destUrl, `/groups/${destinationGroupPath}`);
393
437
  const fallback = () => {
@@ -463,14 +507,24 @@ async function directTransfer(options) {
463
507
  console.log(`Fetching source group from ID: ${options.groupId}...`);
464
508
  const sourceGroup = await source.getGroup(options.groupId);
465
509
 
466
- let destinationGroupName = options.newName || sourceGroup.name;
467
510
  let destinationGroupPath = options.newName || sourceGroup.path;
468
511
 
469
- const sourceProjects = await source.getGroupProjects(sourceGroup.id);
512
+ let sourceProjects;
513
+ try {
514
+ sourceProjects = await source.listGroupProjectsGraphQL(sourceGroup.full_path, {
515
+ includeSubgroups: true,
516
+ pageSize: 100,
517
+ maxProjects: 10000,
518
+ });
519
+ } catch (e) {
520
+ console.warn(`[WARN] GraphQL listing failed (${e.message}). Falling back to REST safe listing...`);
521
+ sourceProjects = await source.getGroupProjects(sourceGroup.id);
522
+ }
523
+
470
524
  console.log(`Found ${sourceProjects.length} projects in source group`);
471
525
  if (sourceProjects.length > 0) {
472
526
  console.log('Projects to be migrated:');
473
- sourceProjects.forEach(p => console.log(`${p.name_with_namespace}`));
527
+ sourceProjects.forEach(p => console.log(p.name_with_namespace || p.nameWithNamespace || p.fullPath));
474
528
  }
475
529
 
476
530
  if (options.newName) {
@@ -479,7 +533,6 @@ async function directTransfer(options) {
479
533
 
480
534
  // Generate URL mapping JSON before starting the migration
481
535
  await generateUrlMappingFile({
482
- sourceUrl,
483
536
  destUrl,
484
537
  sourceGroup,
485
538
  destinationGroupPath,
@@ -56,7 +56,7 @@ async function main(options) {
56
56
  const decomposedCrn = decomposeCrn(toolchainCrn);
57
57
  toolchainId = decomposedCrn.serviceInstance;
58
58
  region = decomposedCrn.location;
59
- } catch (e) {
59
+ } catch {
60
60
  throw Error('Provided toolchain CRN is invalid');
61
61
  }
62
62
 
@@ -107,12 +107,7 @@ class Logger {
107
107
 
108
108
  async withSpinner(asyncFn, loadingMsg, successMsg, prefix, ...args) {
109
109
  if (this.verbosity < 1 || DISABLE_SPINNER) {
110
- try {
111
- return await asyncFn(...args);
112
- }
113
- catch (err) {
114
- throw (err);
115
- }
110
+ return await asyncFn(...args);
116
111
  }
117
112
 
118
113
  this.spinner = ora({
@@ -327,10 +327,11 @@ async function getGritUserProject(privToken, region, user, projectName) {
327
327
  };
328
328
  const response = await axios(options);
329
329
  switch (response.status) {
330
- case 200:
330
+ case 200: {
331
331
  const found = response.data?.find((entry) => entry['path'] === projectName);
332
332
  if (!found) throw Error('GRIT user project not found');
333
333
  return;
334
+ }
334
335
  default:
335
336
  throw Error('Get GRIT user project failed');
336
337
  }
@@ -348,10 +349,11 @@ async function getGritGroup(privToken, region, groupName) {
348
349
  };
349
350
  const response = await axios(options);
350
351
  switch (response.status) {
351
- case 200:
352
+ case 200: {
352
353
  const found = response.data?.find((entry) => entry['full_path'] === groupName);
353
354
  if (!found) throw Error('GRIT group not found');
354
355
  return found['id'];
356
+ }
355
357
  default:
356
358
  throw Error('Get GRIT group failed');
357
359
  }
@@ -370,10 +372,11 @@ async function getGritGroupProject(privToken, region, groupId, projectName) {
370
372
  };
371
373
  const response = await axios(options);
372
374
  switch (response.status) {
373
- case 200:
375
+ case 200: {
374
376
  const found = response.data?.find((entry) => entry['path'] === projectName);
375
377
  if (!found) throw Error('GRIT group project not found');
376
378
  return;
379
+ }
377
380
  default:
378
381
  throw Error('Get GRIT group project failed');
379
382
  }
@@ -479,24 +482,24 @@ async function migrateToolchainSecrets(bearer, data, region) {
479
482
 
480
483
  // GET with retry for flaky 5xx/520 errors (Cloudflare / origin issues)
481
484
  async function getWithRetry(client, path, params = {}, { retries = 3, retryDelayMs = 2000 } = {}) {
482
- let lastError;
483
- for (let attempt = 1; attempt <= retries; attempt++) {
484
- try {
485
- return await client.get(path, { params });
486
- } catch (error) {
487
- const status = error.response?.status;
488
- if (attempt < retries && status && status >= 500) {
489
- console.warn(
490
- `[WARN] GET ${path} failed with status ${status} (attempt ${attempt}/${retries}). Retrying...`
491
- );
492
- await new Promise(resolve => setTimeout(resolve, retryDelayMs * attempt));
493
- lastError = error;
494
- continue;
495
- }
496
- throw error; // Non-5xx or out of retries: rethrow
485
+ let lastError;
486
+ for (let attempt = 1; attempt <= retries; attempt++) {
487
+ try {
488
+ return await client.get(path, { params });
489
+ } catch (error) {
490
+ const status = error.response?.status;
491
+ if (attempt < retries && status && status >= 500) {
492
+ console.warn(
493
+ `[WARN] GET ${path} failed with status ${status} (attempt ${attempt}/${retries}). Retrying...`
494
+ );
495
+ await new Promise(resolve => setTimeout(resolve, retryDelayMs * attempt));
496
+ lastError = error;
497
+ continue;
498
+ }
499
+ throw error; // Non-5xx or out of retries: rethrow
500
+ }
497
501
  }
498
- }
499
- throw lastError;
502
+ throw lastError;
500
503
  }
501
504
 
502
505
  export {
@@ -31,7 +31,7 @@ const writeFilePromise = promisify(fs.writeFile)
31
31
  async function execPromise(command, options) {
32
32
  try {
33
33
  const exec = promisify(child_process.exec);
34
- const { stdout, stderr } = await exec(command, options);
34
+ const { stdout, _ } = await exec(command, options);
35
35
  return stdout.trim();
36
36
  } catch (err) {
37
37
  throw new Error(`Command failed: ${command} \n${err.stderr || err.stdout}`);
@@ -419,7 +419,6 @@ async function runTerraformApply(skipTfConfirmation, outputDir, verbosity, targe
419
419
  });
420
420
 
421
421
  let stdoutData = '';
422
- let stderrData = '';
423
422
 
424
423
  child.stdout.on('data', (chunk) => {
425
424
  const text = chunk.toString();
@@ -432,7 +431,6 @@ async function runTerraformApply(skipTfConfirmation, outputDir, verbosity, targe
432
431
 
433
432
  child.stderr.on('data', (chunk) => {
434
433
  const text = chunk.toString();
435
- stderrData += text;
436
434
  if (verbosity >= 1) {
437
435
  process.stderr.write(text);
438
436
  logger.dump(text);
@@ -86,8 +86,7 @@ async function warnDuplicateName(token, accountId, tcName, srcRegion, targetRegi
86
86
  const toolchains = await getToolchainsByName(token, accountId, tcName);
87
87
 
88
88
  let hasSameRegion = false;
89
- let hasSameResourceGroup = false;
90
- let hasBoth = false;
89
+ let hasBoth = false; // same region and resource group
91
90
 
92
91
  if (toolchains.length > 0) {
93
92
  let newTcName = tcName;
@@ -100,8 +99,6 @@ async function warnDuplicateName(token, accountId, tcName, srcRegion, targetRegi
100
99
  } else {
101
100
  hasSameRegion = true;
102
101
  }
103
- } else if (tc.resource_group_id === targetResourceGroupId) {
104
- hasSameResourceGroup = true;
105
102
  }
106
103
  });
107
104
 
@@ -117,10 +114,6 @@ async function warnDuplicateName(token, accountId, tcName, srcRegion, targetRegi
117
114
  // soft warning of confusion
118
115
  logger.warn(`\nWarning! A toolchain named '${tcName}' already exists in:\n - Region: ${targetRegion}`, '', true);
119
116
  }
120
- // if (hasSameResourceGroup) {
121
- // // soft warning of confusion
122
- // logger.warn(`\nWarning! A toolchain named '${tcName}' already exists in:\n - Resource Group: ${targetResourceGroupName} (${targetResourceGroupId})`, '', true);
123
- // }
124
117
  }
125
118
 
126
119
  if (hasBoth || hasSameRegion) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ibm-cloud/cd-tools",
3
- "version": "1.10.0",
3
+ "version": "1.11.1",
4
4
  "description": "Tools and utilities for the IBM Cloud Continuous Delivery service and resources",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Licensed Materials - Property of IBM
3
- * (c) Copyright IBM Corporation 2025. All Rights Reserved.
3
+ * (c) Copyright IBM Corporation 2025, 2026. All Rights Reserved.
4
4
  *
5
5
  * Note to U.S. Government Users Restricted Rights:
6
6
  * Use, duplication or disclosure restricted by GSA ADP Schedule
@@ -15,7 +15,7 @@ import { expect, assert } from 'chai';
15
15
 
16
16
  import { assertPtyOutput, assertExecError, areFilesInDir, deleteCreatedToolchains, parseTcIdAndRegion } from '../utils/testUtils.js';
17
17
  import { getBearerToken, getToolchain } from '../../cmd/utils/requests.js';
18
- import { TEST_TOOLCHAINS, DEFAULT_RG_ID, R2R_CLI_RG_ID } from '../data/test-toolchains.js';
18
+ import { TEST_TOOLCHAINS, DEFAULT_RG_ID } from '../data/test-toolchains.js';
19
19
  import { TARGET_REGIONS } from '../../config.js';
20
20
 
21
21
  nconf.env('__');
@@ -53,16 +53,17 @@ describe('copy-toolchain: Test functionalities', function () {
53
53
  timeout: 10000
54
54
  }
55
55
  },
56
- {
57
- name: 'Check if CD instance exists in target region',
58
- cmd: [CLI_PATH, COMMAND, '-c', TEST_TOOLCHAINS['empty'].crn, '-r', TARGET_REGIONS[0]],
59
- expected: new RegExp(`Could not find a Continuous Delivery instance in the target region '${TARGET_REGIONS[0]}', please create one before proceeding.`),
60
- options: {
61
- exitCondition: `Could not find a Continuous Delivery instance in the target region '${TARGET_REGIONS[0]}', please create one before proceeding.`,
62
- timeout: 10000,
63
- env: { ...process.env, MOCK_ALL_REQUESTS: 'true', MOCK_GET_CD_INSTANCE_BY_REGION_SCENARIO: 'NOT_FOUND' }
64
- }
65
- },
56
+ // TODO: update outdated test from when missing cd instance would fail the command
57
+ // {
58
+ // name: 'Check if CD instance exists in target region',
59
+ // cmd: [CLI_PATH, COMMAND, '-c', TEST_TOOLCHAINS['empty'].crn, '-r', TARGET_REGIONS[0]],
60
+ // expected: new RegExp(`Could not find a Continuous Delivery instance in the target region '${TARGET_REGIONS[0]}', please create one before proceeding.`),
61
+ // options: {
62
+ // exitCondition: `Could not find a Continuous Delivery instance in the target region '${TARGET_REGIONS[0]}', please create one before proceeding.`,
63
+ // timeout: 10000,
64
+ // env: { ...process.env, MOCK_ALL_REQUESTS: 'true', MOCK_GET_CD_INSTANCE_BY_REGION_SCENARIO: 'NOT_FOUND' }
65
+ // }
66
+ // },
66
67
  {
67
68
  name: 'Log file is created successfully',
68
69
  cmd: [CLI_PATH, COMMAND, '-c', TEST_TOOLCHAINS['empty'].crn, '-r', TEST_TOOLCHAINS['empty'].region],