@ibm-cloud/cd-tools 1.5.2 → 1.6.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.
@@ -22,25 +22,22 @@ import { Command, Option } from 'commander';
22
22
  import { parseEnvVar } 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
- import { getAccountId, getBearerToken, getCdInstanceByRegion, getResourceGroupIdAndName, getToolchain } from './utils/requests.js';
25
+ import { getAccountId, getBearerToken, getCdInstanceByRegion, getResourceGroups, getToolchain } from './utils/requests.js';
26
26
  import { validatePrereqsVersions, validateTag, validateToolchainId, validateToolchainName, validateTools, validateOAuth, warnDuplicateName, validateGritUrl } from './utils/validate.js';
27
27
  import { importTerraform } from './utils/import-terraform.js';
28
28
 
29
- import { COPY_TOOLCHAIN_DESC, DOCS_URL, TARGET_REGIONS, SOURCE_REGIONS } from '../config.js';
29
+ import { COPY_TOOLCHAIN_DESC, TARGET_REGIONS, SOURCE_REGIONS } from '../config.js';
30
30
 
31
31
  import packageJson from '../package.json' with { type: "json" };
32
32
 
33
- process.on('exit', (code) => {
34
- if (code !== 0) logger.print(`Need help? Visit ${DOCS_URL} for more troubleshooting information.`);
35
- });
36
-
37
33
  const TIME_SUFFIX = new Date().getTime();
38
34
  const LOGS_DIR = '.logs';
39
35
  const TEMP_DIR = '.migration-temp-' + TIME_SUFFIX;
40
36
  const LOG_DUMP = process.env['LOG_DUMP'] === 'false' ? false : true; // when true or not specified, logs are also written to a log file in LOGS_DIR
41
- const DEBUG_MODE = process.env['DEBUG_MODE'] === 'true' ? true : false; // when true, temp folder is preserved
37
+ const DEBUG_MODE = process.env['DEBUG_MODE'] === 'true'; // when true, temp folder is preserved
42
38
  const OUTPUT_DIR = 'output-' + TIME_SUFFIX;
43
39
  const DRY_RUN = false; // when true, terraform apply does not run
40
+ const CLOUD_PLATFORM = process.env['IBMCLOUD_PLATFORM_DOMAIN'] || 'cloud.ibm.com';
44
41
 
45
42
 
46
43
  const command = new Command('copy-toolchain')
@@ -106,7 +103,7 @@ async function main(options) {
106
103
  // Validate arguments are valid and check if Terraform is installed appropriately
107
104
  try {
108
105
  validatePrereqsVersions();
109
- logger.info(`\x1b[32m✔\x1b[0m cd-tools Version: ${packageJson.version}`, LOG_STAGES.setup);
106
+ logger.info(`\x1b[32m✔\x1b[0m cd-tools Version: ${packageJson.version}`, LOG_STAGES.setup);
110
107
 
111
108
  if (!apiKey) apiKey = parseEnvVar('IBMCLOUD_API_KEY');
112
109
  bearer = await getBearerToken(apiKey);
@@ -163,8 +160,8 @@ async function main(options) {
163
160
  exit(1);
164
161
  }
165
162
 
166
- ({ id: targetRgId, name: targetRgName } = await getResourceGroupIdAndName(bearer, accountId, targetRg || sourceToolchainData['resource_group_id']));
167
-
163
+ const resourceGroups = await getResourceGroups(bearer, accountId, [targetRg || sourceToolchainData['resource_group_id']]);
164
+ ({ id: targetRgId, name: targetRgName } = resourceGroups[0])
168
165
  // reuse name if not provided
169
166
  if (!targetToolchainName) targetToolchainName = sourceToolchainData['name'];
170
167
  [targetToolchainName, targetTag] = await warnDuplicateName(bearer, accountId, targetToolchainName, sourceRegion, targetRegion, targetRgId, targetRgName, targetTag, skipUserConfirmation);
@@ -321,7 +318,7 @@ async function main(options) {
321
318
 
322
319
  // copy script
323
320
  const s2sScript = fs.readFileSync(resolve(__dirname, '../create-s2s-script.js'));
324
- fs.writeFileSync(resolve(`${outputDir}/create-s2s-script.js`), s2sScript);
321
+ fs.writeFileSync(resolve(`${outputDir}/create-s2s-script.cjs`), s2sScript);
325
322
  }
326
323
 
327
324
  // create toolchain, which invokes script to create s2s if applicable
@@ -341,7 +338,7 @@ async function main(options) {
341
338
 
342
339
  logger.print('\n');
343
340
  logger.info(`Toolchain "${sourceToolchainData['name']}" from ${sourceRegion} was cloned to "${targetToolchainName ?? sourceToolchainData['name']}" in ${targetRegion} ${applyErrors ? 'with some errors' : 'successfully'}, with ${numResourcesCreated} / ${numResourcesPlanned} resources created!`, LOG_STAGES.info);
344
- if (newTcId) logger.info(`See cloned toolchain: https://cloud.ibm.com/devops/toolchains/${newTcId}?env_id=ibm:yp:${targetRegion}`, LOG_STAGES.info, true);
341
+ if (newTcId) logger.info(`See cloned toolchain: https://${CLOUD_PLATFORM}/devops/toolchains/${newTcId}?env_id=ibm:yp:${targetRegion}`, LOG_STAGES.info, true);
345
342
  } else {
346
343
  logger.info(`DRY_RUN: ${dryRun}, skipping terraform apply...`, LOG_STAGES.tf);
347
344
  }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Licensed Materials - Property of IBM
3
+ * (c) Copyright IBM Corporation 2025. All Rights Reserved.
4
+ *
5
+ * Note to U.S. Government Users Restricted Rights:
6
+ * Use, duplication or disclosure restricted by GSA ADP Schedule
7
+ * Contract with IBM Corp.
8
+ */
9
+ 'use strict';
10
+
11
+ import { exit } from 'node:process';
12
+ import { Command } from 'commander';
13
+ import { parseEnvVar, decomposeCrn, isSecretReference, promptUserSelection, promptUserYesNo, promptUserInput } from './utils/utils.js';
14
+ import { logger, LOG_STAGES } from './utils/logger.js';
15
+ import { getBearerToken, getToolchain, getToolchainTools, getPipelineData, getSmInstances, createTool, getAccountId, getResourceGroups, migrateToolchainSecrets } from './utils/requests.js';
16
+ import { SECRET_KEYS_MAP } from '../config.js';
17
+
18
+ const CLOUD_PLATFORM = process.env['IBMCLOUD_PLATFORM_DOMAIN'] || 'cloud.ibm.com';
19
+
20
+ const command = new Command('export-secrets')
21
+ .description('Exports Toolchain stored secrets to a Secrets Manager instance')
22
+ .requiredOption('-c, --toolchain-crn <crn>', 'The CRN of the toolchain to check')
23
+ .option('-a, --apikey <api_key>', 'API key used to authenticate. Must have IAM permission to read toolchains and create secrets in Secrets Manager')
24
+ .option('--check', '(Optional) Checks and lists any stored secrets in your toolchain')
25
+ .option('-v, --verbose', '(Optional) Increase log output')
26
+ .showHelpAfterError()
27
+ .hook('preAction', cmd => cmd.showHelpAfterError(false)) // only show help during validation
28
+ .action(main);
29
+
30
+ async function main(options) {
31
+ const toolchainCrn = options.toolchainCrn;
32
+ const verbosity = options.verbose ? 2 : 1;
33
+ const runMigration = !options.check;
34
+
35
+ logger.setVerbosity(verbosity);
36
+
37
+ let apiKey;
38
+ let bearer;
39
+ let accountId;
40
+ let toolchainId;
41
+ let region;
42
+ let toolchainData;
43
+ let tools;
44
+
45
+ try {
46
+ const toolResults = [];
47
+ const pipelineResults = [];
48
+
49
+ apiKey = options.apikey || parseEnvVar('IBMCLOUD_API_KEY');
50
+ if (!apiKey) {
51
+ logger.error('Missing IBM Cloud IAM API key', LOG_STAGES.setup);
52
+ exit(1);
53
+ };
54
+
55
+ try {
56
+ const decomposedCrn = decomposeCrn(toolchainCrn);
57
+ toolchainId = decomposedCrn.serviceInstance;
58
+ region = decomposedCrn.location;
59
+ } catch (e) {
60
+ throw Error('Provided toolchain CRN is invalid');
61
+ }
62
+
63
+ // Display toolchain data to user
64
+ const getToolchainData = async () => {
65
+ bearer = await getBearerToken(apiKey);
66
+ toolchainData = await getToolchain(bearer, toolchainId, region);
67
+ }
68
+ await logger.withSpinner(getToolchainData, `Reading Toolchain`, 'Valid Toolchain found!');
69
+ logger.print(`Name: ${toolchainData.name}\nRegion: ${region}\nResource Group ID: ${toolchainData.resource_group_id}\nURL:https://${CLOUD_PLATFORM}/devops/toolchains/${toolchainId}?env_id=ibm:yp:${region}\n`);
70
+
71
+ // Check for plain-text secrets in all tools
72
+ const exportSecrets = async () => {
73
+ const getToolsRes = await getToolchainTools(bearer, toolchainId, region);
74
+ tools = getToolsRes.tools;
75
+
76
+ if (tools.length > 0) {
77
+ for (let i = 0; i < tools.length; i++) {
78
+ const tool = tools[i];
79
+ const toolUrl = `https://${CLOUD_PLATFORM}/devops/toolchains/${tool.toolchain_id}/configure/${tool.id}?env_id=ibm:yp:${region}`;
80
+ const toolName = (tool.name || tool.parameters?.name || tool.parameters?.label || '').replace(/\s+/g, '+');
81
+
82
+ // Skip iff it's GitHub/GitLab/GRIT integration with OAuth
83
+ if (['githubconsolidated', 'github_integrated', 'gitlab', 'hostedgit'].includes(tool.tool_type_id) && (tool.parameters?.auth_type === '' || tool.parameters?.auth_type === 'oauth'))
84
+ continue;
85
+
86
+ // Check tool integrations for any plain text secret values
87
+ if (SECRET_KEYS_MAP[tool.tool_type_id]) {
88
+ SECRET_KEYS_MAP[tool.tool_type_id].forEach((entry) => {
89
+ const updateableSecretParam = entry.key;
90
+ if (tool.parameters[updateableSecretParam] && !isSecretReference(tool.parameters[updateableSecretParam]) && tool.parameters[updateableSecretParam].length > 0) {
91
+ toolResults.push({
92
+ 'Tool ID': tool.id,
93
+ 'Tool Name': toolName,
94
+ 'Tool Type': tool.tool_type_id,
95
+ 'Property Name': updateableSecretParam,
96
+ 'Url': toolUrl
97
+ });
98
+ };
99
+ });
100
+ };
101
+
102
+ // For tekton pipelines, check for any plain text secret properties
103
+ if (tool.tool_type_id === 'pipeline' && tool.parameters?.type === 'tekton') {
104
+ const pipelineBaseUrl = `https://${CLOUD_PLATFORM}/devops/pipelines/tekton/${tool.id}`
105
+ const pipelineData = await getPipelineData(bearer, tool.id, region);
106
+
107
+ pipelineData?.properties.forEach((prop) => {
108
+ if (prop.type === 'secure' && !isSecretReference(prop.value) && prop.value.length > 0) {
109
+ pipelineResults.push({
110
+ 'Pipeline ID': pipelineData.id,
111
+ 'Pipeline Name': toolName,
112
+ 'Trigger Name': '',
113
+ 'Property Name': prop.name,
114
+ 'Url': pipelineBaseUrl + `/config/envProperties?env_id=ibm:yp:${region}`,
115
+ });
116
+ };
117
+ });
118
+
119
+ pipelineData?.triggers.forEach((trigger) => {
120
+ trigger.properties?.forEach((prop) => {
121
+ if (prop.type === 'secure' && !isSecretReference(prop.value) && prop.value.length > 0) {
122
+ pipelineResults.push({
123
+ 'Pipeline ID': pipelineData.id,
124
+ 'Pipeline Name': toolName,
125
+ 'Trigger Name': trigger.name,
126
+ 'Trigger ID': trigger.id,
127
+ 'Property Name': prop.name,
128
+ 'Url': pipelineBaseUrl + `?env_id=ibm:yp:${region}`
129
+ });
130
+ };
131
+ });
132
+ });
133
+ }
134
+ };
135
+ };
136
+ };
137
+
138
+ await logger.withSpinner(exportSecrets, `Checking secrets for toolchain ${toolchainCrn}`, 'Secret check complete!');
139
+
140
+ const numTotalSecrets = toolResults.length + pipelineResults.length;
141
+ if (numTotalSecrets > 0) {
142
+ logger.warn(`\nNote: ${numTotalSecrets} locally stored secret(s) found!`)
143
+ } else {
144
+ logger.success('\nNo locally stored secrets found!');
145
+ }
146
+ if (toolResults.length > 0) {
147
+ logger.print();
148
+ logger.print('The following plain text properties were found in tool integrations bound to the toolchain:');
149
+ logger.table(toolResults, 'Url');
150
+ }
151
+ if (pipelineResults.length > 0) {
152
+ logger.print();
153
+ logger.print('The following plain text properties were found in Tekton pipeline(s) bound to the toolchain:');
154
+ logger.table(pipelineResults, 'Url', ['Trigger ID']);
155
+ }
156
+ if (numTotalSecrets > 0 && !runMigration) {
157
+ logger.warn(`\nNote: ${numTotalSecrets} locally stored secret(s) found!\nSecrets stored locally in Toolchains and Pipelines will not be exported when copying a toolchain. It is recommended that secrets be moved to a Secrets Manager instance and converted to secret references if these secrets are required.`);
158
+ logger.warn(`\nTo migrate secrets to Secrets Manager, ensure that you have provisioned an instance of Secrets Manager which you have write access to and rerun the command without the additional param '--check' to move the secrets into Secrets Manager.`)
159
+ }
160
+
161
+ // Facilitate Secrets Migration
162
+ const migrateSecrets = async () => {
163
+ accountId = await getAccountId(bearer, apiKey);
164
+
165
+ let allSmInstances = await getSmInstances(bearer, accountId);
166
+ if (allSmInstances.length === 0) {
167
+ logger.warn('No Secrets Manager instances found. Please create a Secrets Manager instance and try again.');
168
+ return;
169
+ }
170
+
171
+ const resourceGroups = await getResourceGroups(bearer, accountId, allSmInstances.map(inst => inst.resource_group_id));
172
+ const groupNameById = Object.fromEntries(
173
+ resourceGroups.map(g => [g.id, g.name])
174
+ );
175
+ allSmInstances = allSmInstances.map(inst => ({
176
+ ...inst,
177
+ resource_group_name: groupNameById[inst.resource_group_id] || 'Unknown'
178
+ }));
179
+
180
+ const instanceChoice = await promptUserSelection(
181
+ 'Select a Secrets Manager Instance to migrate secret(s) to:',
182
+ allSmInstances.map(inst => (`\n Name: ${inst.name} (${inst.id})\n Region: ${inst.region_id}\n Resource Group: ${inst.resource_group_name}`))
183
+ );
184
+ const smInstance = allSmInstances[instanceChoice];
185
+
186
+ // Check if there's an existing Secrets Manager tool integration
187
+ let hasSmIntegration = false;
188
+ for (const tool of tools) {
189
+ if (tool.state === 'configured' && tool.tool_type_id === 'secretsmanager') {
190
+ if (
191
+ (tool.parameters?.['instance-id-type'] === 'instance-name' && tool.parameters?.['instance-name'] === smInstance.name &&
192
+ tool.parameters?.region === smInstance.region_id && tool.parameters?.['resource-group'] === smInstance.resource_group_name) ||
193
+ (tool.parameters?.['instance-id-type'] === 'instance-crn' && tool.parameters?.['instance-crn'] === smInstance.crn)
194
+ ) {
195
+ hasSmIntegration = true;
196
+ break;
197
+ }
198
+ }
199
+ }
200
+
201
+ // Prompt user to create a Secrets Manager tool integration if it doesn't already exist
202
+ if (!hasSmIntegration) {
203
+ logger.warn('No valid Secrets Manager tool integration found.');
204
+ const toCreateSmTool = await promptUserYesNo(`Create a Secrets Manager tool integration?`);
205
+ if (!toCreateSmTool) {
206
+ logger.warn('Toolchain secrets will not be migrated to Secrets Manager. Please create a Secrets Manager tool integration and try again.');
207
+ return;
208
+ }
209
+ const smToolName = await promptUserInput(`Enter the name of the Secrets Manager tool integration to create [Press 'enter' to skip]: `, '', async (input) => {
210
+ if (input.length > 128) {
211
+ throw new Error('The tool integration name must be between 0 and 128 characters long.');
212
+ }
213
+ // from https://cloud.ibm.com/apidocs/toolchain#create-tool
214
+ else if (input !== '' && !/^([^\x00-\x7F]|[a-zA-Z0-9-._ ])+$/.test(input)) {
215
+ throw new Error('Provided tool integration name contains invalid characters.');
216
+ }
217
+ });
218
+
219
+ const smToolParams = {
220
+ 'tool_type_id': 'secretsmanager',
221
+ 'parameters': {
222
+ 'name': smToolName || 'Secrets Manager',
223
+ 'instance-id-type': 'instance-crn',
224
+ 'instance-crn': smInstance.crn,
225
+ }
226
+ };
227
+ try {
228
+ const smTool = await createTool(bearer, toolchainId, region, smToolParams);
229
+ logger.success(`Secrets Manager tool integration created: ${smTool.parameters.name} (${smTool.id})`);
230
+ const smToolUrl = `https://${CLOUD_PLATFORM}/devops/toolchains/${toolchainId}/configure/${smTool.id}?env_id=ibm:yp:${region}`;
231
+ logger.warn(`Create necessary IAM service authorization for toolchain to access Secrets Manager service instance:\n${smToolUrl}`);
232
+ } catch (e) {
233
+ logger.error(`Failed to create Secrets Manager tool integration: ${e.message}`);
234
+ throw e;
235
+ }
236
+ }
237
+
238
+ let numSecretsMigrated = 0;
239
+ const allSecrets = toolResults.concat(pipelineResults);
240
+ for (let i = 0; i < allSecrets.length; i++) {
241
+ logger.print('-------');
242
+ const secret = allSecrets[i];
243
+ const toolName = secret['Tool Name'] || secret['Pipeline Name'];
244
+ const toolType = secret['Tool Type'] || 'pipeline';
245
+ const toolId = secret['Tool ID'] || secret['Pipeline ID'];
246
+ const triggerId = secret['Trigger ID'];
247
+ const triggerName = secret['Trigger Name'];
248
+ const toolSecretKey = secret['Property Name'];
249
+ const toolSecretUrl = secret['Url'];
250
+ const secretPath = `${toolName || toolType}.${triggerName ? triggerName + '.' : ''}${toolSecretKey}`;
251
+
252
+ logger.print(`[${i + 1}]\n Tool integration: ${toolName ? `'${toolName}' (${toolType})` : toolType}\n Property: '${triggerName ? triggerName + '.' : ''}${toolSecretKey}'\n URL: ${toolSecretUrl}\n`);
253
+
254
+ const shouldMigrateSecret = await promptUserYesNo(`Migrate this secret to Secrets Manager instance '${smInstance.name}'?`);
255
+ if (!shouldMigrateSecret) {
256
+ continue;
257
+ }
258
+
259
+ const smSecretName = await promptUserInput(`Enter the name of the secret to create [${secretPath}]: `, '', async (input) => {
260
+ if (input.length < 2 || input.length > 256) {
261
+ throw new Error('The secret name must be between 2 and 256 characters long.');
262
+ }
263
+ // from https://cloud.ibm.com/apidocs/secrets-manager/secrets-manager-v2#create-secret
264
+ else if (!/^[A-Za-z0-9_][A-Za-z0-9_]*(?:_*-*\.*[A-Za-z0-9]*)*[A-Za-z0-9]+$/.test(input)) {
265
+ throw new Error('Provided secret name contains invalid characters.');
266
+ }
267
+ });
268
+
269
+ const smSecretGroupId = await promptUserInput(`Enter the ID of the secret group to create secret '${smSecretName}' in: `, 'default', async (input) => {
270
+ if (input.length < 7 || input.length > 36) {
271
+ throw new Error('The secret group name must be between 7 and 36 characters long.');
272
+ }
273
+ // from https://cloud.ibm.com/apidocs/secrets-manager/secrets-manager-v2#create-secret
274
+ else if (!/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|default)$/.test(input)) {
275
+ throw new Error('Provided secret group name is invalid. It should be a UUID or the word \'default\'');
276
+ }
277
+ });
278
+
279
+ try {
280
+ const commonProps = {
281
+ toolchain_id: toolchainId,
282
+ destination: {
283
+ is_private: false, // TODO: set this back to 'true' once 'otc-api' has the 'export_secret' endpoint, should always use SM private endpoint
284
+ is_production: CLOUD_PLATFORM === 'cloud.ibm.com',
285
+ secrets_manager_crn: smInstance.crn,
286
+ secret_name: smSecretName,
287
+ secret_group_id: smSecretGroupId
288
+ }
289
+ };
290
+ const payload = {
291
+ source: {
292
+ type: toolType === 'pipeline' ? toolType : 'tool',
293
+ id: toolType === 'pipeline' ? (triggerId || toolId) : toolId,
294
+ secret_key: toolSecretKey,
295
+ kind: toolType === 'pipeline' ? (triggerId ? 'trigger' : 'env') : undefined,
296
+ parent_id: toolType === 'pipeline' ? (triggerId ? toolId : undefined) : undefined
297
+ },
298
+ ...commonProps
299
+ };
300
+
301
+ const smSecretUrl = await migrateToolchainSecrets(bearer, payload, region);
302
+ logger.success(`Secret successfully migrated!\nSecret URL: ${smSecretUrl}`);
303
+ numSecretsMigrated += 1;
304
+ }
305
+ catch (e) {
306
+ logger.error(`Failed to migrate secret '${secretPath}'. Error message: ${e.message}`, '', true);
307
+ }
308
+ }
309
+ logger.success(`Toolchain secrets migration complete, ${numSecretsMigrated} secret(s) successfully migrated.`);
310
+ };
311
+
312
+ if (numTotalSecrets > 0 && runMigration) await migrateSecrets();
313
+ }
314
+ catch (err) {
315
+ if (err.message && err.stack) {
316
+ const errMsg = verbosity > 1 ? err.stack : err.message;
317
+ logger.error(errMsg);
318
+ }
319
+ exit(1);
320
+ }
321
+ };
322
+
323
+ export default command;
package/cmd/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * Contract with IBM Corp.
8
8
  */
9
9
 
10
- import checkSecrets from './check-secrets.js';
10
+ import exportSecrets from './export-secrets.js';
11
11
  import copyToolchain from './copy-toolchain.js';
12
12
  import directTransfer from './direct-transfer.js';
13
- export { checkSecrets, copyToolchain, directTransfer };
13
+ export { exportSecrets, copyToolchain, directTransfer };
@@ -60,9 +60,9 @@ class Logger {
60
60
 
61
61
  #baseLog(type, msg, prefix) {
62
62
  const level = LEVELS[type] || LEVELS.log;
63
- const formatted = this.#getFullPrefix(prefix) + ' ' + `${level.color}${msg}${COLORS.reset}`;
63
+ const formatted = (prefix ? this.#getFullPrefix(prefix) + ' ' : '') + `${level.color}${msg}${COLORS.reset}`;
64
64
  console[level.method](formatted);
65
- this.logStream?.write(stripAnsi(this.#getFullPrefix(prefix) + ` [${type.toUpperCase()}] ` + msg) + '\n');
65
+ this.logStream?.write(stripAnsi((prefix ? this.#getFullPrefix(prefix) + ' ' : '') + `[${type.toUpperCase()}] ` + msg) + '\n');
66
66
  }
67
67
 
68
68
  info(msg, prefix = '', force = false) { if (this.verbosity >= 1 || force) this.#baseLog('info', msg, prefix); }
@@ -132,10 +132,10 @@ class Logger {
132
132
  return res;
133
133
  }
134
134
 
135
- table(data, rowSpanField = 'url') {
135
+ table(data, rowSpanField = 'url', colsToSkip = []) {
136
136
  if (!Array.isArray(data) || data.length < 1) return;
137
137
  const tableData = structuredClone(data);
138
- const headers = Object.keys(tableData[0]).filter(key => key !== rowSpanField);
138
+ const headers = Object.keys(tableData[0]).filter(key => key !== rowSpanField && !colsToSkip.includes(key));
139
139
  const t = new Table({
140
140
  head: headers,
141
141
  style: { head: ['cyan'] }
@@ -151,13 +151,18 @@ class Logger {
151
151
  delete row[rowKey];
152
152
  }
153
153
  tableRow.push(
154
- ...Object.values(row).map(val => {
155
- if (Array.isArray(val))
156
- return val.map((item, idx) => `${idx + 1}: ${item}`).join('\n');
157
- else if (typeof val === 'string')
158
- return val;
159
- return JSON.stringify(val);
160
- })
154
+ ...Object.entries(row)
155
+ .filter(([key]) => !colsToSkip.includes(key))
156
+ .map(([_, val]) => {
157
+ if (Array.isArray(val))
158
+ return val
159
+ .map((item, idx) => `${idx + 1}: ${item ?? '-'}`).join('\n');
160
+ else if (val === '')
161
+ return '-';
162
+ else if (typeof val === 'string')
163
+ return val;
164
+ return JSON.stringify(val);
165
+ })
161
166
  );
162
167
  t.push(tableRow);
163
168
  if (rowSpanFieldVal !== '') t.push([{ content: rowSpanFieldVal, colSpan: headers.length - 1 }]);
@@ -172,5 +177,6 @@ export const LOG_STAGES = {
172
177
  setup: 'setup',
173
178
  import: 'import',
174
179
  tf: 'terraform',
175
- info: 'info'
180
+ info: 'info',
181
+ request: 'request'
176
182
  };