@ibm-cloud/cd-tools 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.
@@ -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,7 +11,7 @@ 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
@@ -186,26 +186,26 @@ class GitLabClient {
186
186
  }
187
187
  }
188
188
 
189
- async getBulkImportEntitiesAll(importId, { perPage = 100, maxPages = 200 } = {}) {
190
- const all = [];
191
- let page = 1;
189
+ async getBulkImportEntitiesAll(importId, { perPage = 100, maxPages = 200 } = {}) {
190
+ const all = [];
191
+ let page = 1;
192
192
 
193
- while (page <= maxPages) {
194
- const resp = await getWithRetry(
195
- this.client,
196
- `/bulk_imports/${importId}/entities`,
197
- { page, per_page: perPage }
198
- );
193
+ while (page <= maxPages) {
194
+ const resp = await getWithRetry(
195
+ this.client,
196
+ `/bulk_imports/${importId}/entities`,
197
+ { page, per_page: perPage }
198
+ );
199
199
 
200
- all.push(...(resp.data || []));
200
+ all.push(...(resp.data || []));
201
201
 
202
- const nextPage = Number(resp.headers?.['x-next-page'] || 0);
203
- if (!nextPage) break;
202
+ const nextPage = Number(resp.headers?.['x-next-page'] || 0);
203
+ if (!nextPage) break;
204
204
 
205
- page = nextPage;
206
- }
205
+ page = nextPage;
206
+ }
207
207
 
208
- return all;
208
+ return all;
209
209
  }
210
210
 
211
211
  async getGroupByFullPath(fullPath) {
@@ -250,7 +250,7 @@ function validateAndConvertRegion(region) {
250
250
  }
251
251
 
252
252
  // Build a mapping of: old http_url_to_repo -> new http_url_to_repo
253
- async function generateUrlMappingFile({sourceUrl, destUrl, sourceGroup, destinationGroupPath, sourceProjects}) {
253
+ async function generateUrlMappingFile({ _, destUrl, sourceGroup, destinationGroupPath, sourceProjects }) {
254
254
  const destBase = destUrl.endsWith('/') ? destUrl.slice(0, -1) : destUrl;
255
255
  const urlMapping = {};
256
256
 
@@ -387,7 +387,7 @@ function isGroupEntity(e) {
387
387
  return e?.source_type === 'group_entity' || e?.entity_type === 'group_entity' || e?.entity_type === 'group';
388
388
  }
389
389
 
390
- async function handleBulkImportConflict({destination, destUrl, sourceGroupFullPath, destinationGroupPath, importResErr}) {
390
+ async function handleBulkImportConflict({ destination, destUrl, sourceGroupFullPath, destinationGroupPath, importResErr }) {
391
391
  const historyUrl = buildGroupImportHistoryUrl(destUrl);
392
392
  const groupUrl = buildGroupUrl(destUrl, `/groups/${destinationGroupPath}`);
393
393
  const fallback = () => {
@@ -463,7 +463,7 @@ async function directTransfer(options) {
463
463
  console.log(`Fetching source group from ID: ${options.groupId}...`);
464
464
  const sourceGroup = await source.getGroup(options.groupId);
465
465
 
466
- let destinationGroupName = options.newName || sourceGroup.name;
466
+ // let destinationGroupName = options.newName || sourceGroup.name;
467
467
  let destinationGroupPath = options.newName || sourceGroup.path;
468
468
 
469
469
  const sourceProjects = await source.getGroupProjects(sourceGroup.id);
@@ -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) {
@@ -0,0 +1,33 @@
1
+ import js from "@eslint/js";
2
+ import globals from "globals";
3
+ import { defineConfig } from "eslint/config";
4
+
5
+ export default defineConfig([
6
+ {
7
+ files: ["**/*.{js,mjs,cjs}"],
8
+ ignores: ["**/test/**"],
9
+ rules: {
10
+ "no-unused-vars": ["warn", {
11
+ "argsIgnorePattern": "^_",
12
+ "varsIgnorePattern": "^_",
13
+ "caughtErrorsIgnorePattern": "^_"
14
+ }],
15
+ "no-useless-catch": "warn",
16
+ "no-case-declarations": "warn",
17
+ "no-useless-escape": "off",
18
+ "no-control-regex": "off",
19
+ "no-regex-spaces": "off",
20
+ },
21
+ plugins: {
22
+ js
23
+ },
24
+ extends: ["js/recommended"],
25
+ languageOptions: {
26
+ globals: {
27
+ ...globals.browser,
28
+ process: "readonly",
29
+ require: "readonly",
30
+ }
31
+ }
32
+ },
33
+ ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ibm-cloud/cd-tools",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "Tools and utilities for the IBM Cloud Continuous Delivery service and resources",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,6 +33,7 @@
33
33
  "type": "module",
34
34
  "devDependencies": {
35
35
  "chai": "^6.2.0",
36
+ "eslint": "^9.39.2",
36
37
  "mocha": "^11.7.4",
37
38
  "nconf": "^0.13.0",
38
39
  "node-pty": "^1.0.0"
@@ -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],