Skip to content

Commit a4b60fd

Browse files
authored
feat(marketplace): Add marketplace-cli subcommand to generate CSV files from YAML files (#470)
* feat(marketplace-cli): Create marketplace-cli subcommand to generate CSV files from YAML files implements https://issues.redhat.com/browse/RHIDP-6231 * add backend and frontend plugin columns * Update tekton.yaml * produce 2 csvs * use `spec.packageName` for package name
1 parent efc1592 commit a4b60fd

4 files changed

Lines changed: 301 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/marketplace-cli': minor
3+
---
4+
5+
Added a new command: `export-csv`, which generates a CSV file given a folder of plugin YAML files

workspaces/marketplace/packages/cli/cli-report.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,23 @@ Commands:
1414
init
1515
generate [options]
1616
verify
17+
export-csv [options]
1718
help [command]
1819
```
1920

21+
### `marketplace-cli export-csv`
22+
23+
```
24+
Usage: marketplace-cli export-csv [options]
25+
26+
Options:
27+
-o, --output-file [path]
28+
-p, --plugins-yaml-path [path]
29+
-r, --recursive
30+
-t, --type [type]
31+
-h, --help
32+
```
33+
2034
### `marketplace-cli generate`
2135

2236
```
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/*
2+
* Copyright Red Hat, Inc.
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+
* http://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+
import chalk from 'chalk';
17+
import { Entity } from '@backstage/catalog-model/index';
18+
import {
19+
isMarketplacePackage,
20+
isMarketplacePlugin,
21+
MarketplacePackage,
22+
MarketplacePlugin,
23+
} from '@red-hat-developer-hub/backstage-plugin-marketplace-common';
24+
import type { OptionValues } from 'commander';
25+
import fs from 'fs-extra';
26+
import path from 'path';
27+
import YAML from 'yaml';
28+
import glob from 'glob';
29+
import { JsonValue } from '@backstage/types/index';
30+
31+
/** A simple helper class to generate CSV files */
32+
class CSVGenerator<T> {
33+
/** The columns to be included in the CSV. */
34+
private readonly columns: {
35+
/**
36+
* The column key to be used in the CSV header. It must not contain commas,
37+
* newlines, quotes, or anything else that could break the CSV format. It
38+
* does not have to be unique.
39+
*
40+
* Each column key stores a getter function that takes a row and returns
41+
* the value to be included in the CSV.
42+
*/
43+
[key: string]: (cell: T) => JsonValue | undefined;
44+
};
45+
46+
/** The rows to be included in the CSV */
47+
private readonly rows: T[];
48+
49+
constructor(
50+
columns: CSVGenerator<T>['columns'],
51+
rows: CSVGenerator<T>['rows'] = [],
52+
) {
53+
this.columns = columns;
54+
this.rows = rows;
55+
}
56+
57+
/** Escape problematic characters in a CSV cell */
58+
static readonly escapeCSVCell = (cell: string) => {
59+
const removedNewLines = cell.replace(/\n/g, '\\n');
60+
const regex = /[\s,"]/;
61+
62+
// adapted from https://stackoverflow.com/a/47340986
63+
if (regex.exec(removedNewLines.replace(/ /g, ''))) {
64+
return `"${removedNewLines.replace(/"/g, '""')}"`;
65+
}
66+
67+
return cell;
68+
};
69+
70+
/** Add a row to the CSV */
71+
addRow(row: T) {
72+
this.rows.push(row);
73+
}
74+
75+
/**
76+
* @param includeHeader Whether to include the header row in the CSV
77+
* @returns A CSV generated from the rows and columns provided
78+
*/
79+
generate(includeHeader = true): string {
80+
const header = Object.keys(this.columns)
81+
.map(key => CSVGenerator.escapeCSVCell(key))
82+
.join(',');
83+
const rows = this.rows
84+
.map(row =>
85+
Object.values(this.columns)
86+
.map(column => CSVGenerator.escapeCSVCell(String(column(row) || '')))
87+
.join(','),
88+
)
89+
.join('\n');
90+
91+
return includeHeader ? `${header}\n${rows}` : rows;
92+
}
93+
}
94+
95+
/**
96+
* A type guard for Entity
97+
*
98+
* @param entity Some unknown object to check
99+
* @returns Whether the entity is an Entity
100+
*/
101+
const isEntity = (entity: any): entity is Entity => {
102+
return (
103+
!!entity &&
104+
typeof entity === 'object' &&
105+
'apiVersion' in entity &&
106+
'kind' in entity
107+
);
108+
};
109+
110+
/**
111+
* Parse a list of YAML files into a list of entities
112+
* @param yamls The list of paths of YAML files to parse
113+
* @returns A list of entities
114+
*/
115+
const parseYamls = async (yamls: string[], recurse = false) => {
116+
return await Promise.all(
117+
(yamls || []).flatMap(basePath => {
118+
return glob
119+
.sync(recurse ? '**/*.yaml' : '*.yaml', { cwd: basePath })
120+
.map(async (file: string) => {
121+
const fileContent = await fs.readFile(
122+
path.join(basePath, file),
123+
'utf-8',
124+
);
125+
126+
// read each individual plugin from the file
127+
const plugins = YAML.parseAllDocuments(fileContent)
128+
.map(doc => doc.toJS())
129+
.filter(isEntity);
130+
return plugins;
131+
});
132+
}),
133+
).then(plugins => plugins.flat());
134+
};
135+
136+
const getPackagesOfType = (
137+
packages: string[],
138+
allPackages: Record<string, MarketplacePackage>,
139+
types: string[],
140+
): string => {
141+
return packages
142+
.map(name => {
143+
const pkg = allPackages[name];
144+
if (!pkg) {
145+
console.error(
146+
chalk.red(`Package ${name} not found in the list of packages`),
147+
);
148+
return '';
149+
}
150+
151+
const version = pkg.spec?.version
152+
? `(${pkg.spec?.version})`
153+
: '(no version)';
154+
155+
if (types.includes(pkg?.spec?.backstage?.role || '')) {
156+
return `${pkg.spec?.packageName} ${version}`;
157+
}
158+
159+
return '';
160+
})
161+
.filter(Boolean)
162+
.join(', ');
163+
};
164+
165+
export default async ({
166+
outputFile,
167+
pluginsYamlPath,
168+
recursive,
169+
type,
170+
}: OptionValues) => {
171+
if (!pluginsYamlPath) {
172+
console.error(
173+
'No plugins path provided! Provide one using --plugins-yaml-path',
174+
);
175+
process.exit(1);
176+
}
177+
178+
/** Parse the YAML files into entities */
179+
const yamls = parseYamls(pluginsYamlPath.split(','), recursive);
180+
181+
/** A map of all packages */
182+
const packages: Record<string, MarketplacePackage> = {};
183+
184+
/** The generator for backstage marketplace Packages */
185+
const packageCSV = new CSVGenerator<MarketplacePackage>({
186+
name: yaml => yaml?.metadata?.name,
187+
title: yaml => yaml?.metadata?.title,
188+
version: yaml => yaml?.spec?.version,
189+
author: yaml =>
190+
yaml?.spec?.developer ||
191+
yaml?.spec?.author ||
192+
yaml?.spec?.owner ||
193+
((yaml?.spec?.authors as string[]) || []).join(','),
194+
195+
lifecycle: yaml => yaml?.spec?.lifecycle,
196+
packages: yaml => (yaml?.spec?.partOf || []).join(', '),
197+
role: yaml => yaml?.spec?.role || yaml?.spec?.backstage?.role,
198+
});
199+
200+
/** The generator for backstage marketplace Plugins */
201+
const pluginCSV = new CSVGenerator<MarketplacePlugin>({
202+
name: p => p?.metadata?.name,
203+
title: p => p?.metadata?.title,
204+
description: p => p?.metadata?.description,
205+
author: p =>
206+
p?.spec?.developer ||
207+
p?.spec?.author ||
208+
p?.spec?.owner ||
209+
(p?.spec?.authors || []).join(','),
210+
categories: p => ((p?.spec?.categories as string[]) || []).join(', '),
211+
lifecycle: p => p?.spec?.lifecycle,
212+
packages: p => (p?.spec?.packages || []).join(', '),
213+
'backend packages': p =>
214+
getPackagesOfType(p?.spec?.packages || [], packages, [
215+
'backend-plugin',
216+
'backend-plugin-module',
217+
]),
218+
'frontend packages': p =>
219+
getPackagesOfType(p?.spec?.packages || [], packages, ['frontend-plugin']),
220+
});
221+
222+
/** Process each YAML file */
223+
for (const yaml of await yamls) {
224+
if (isMarketplacePackage(yaml)) {
225+
packages[yaml.metadata.name] = yaml;
226+
packageCSV.addRow(yaml);
227+
} else if (isMarketplacePlugin(yaml)) {
228+
pluginCSV.addRow(yaml);
229+
}
230+
}
231+
232+
/** Generate the final CSV */
233+
const csvs: Record<string, string> = {};
234+
235+
switch (type) {
236+
case 'plugin':
237+
csvs['-plugins.csv'] = pluginCSV.generate();
238+
break;
239+
case 'package':
240+
csvs['-packages.csv'] = packageCSV.generate();
241+
break;
242+
case 'all':
243+
default:
244+
csvs['-plugins.csv'] = pluginCSV.generate();
245+
csvs['-packages.csv'] = packageCSV.generate();
246+
break;
247+
}
248+
249+
for (const [suffix, csv] of Object.entries(csvs)) {
250+
if (!outputFile) {
251+
console.log(csv);
252+
continue;
253+
}
254+
255+
await fs.writeFile(`${outputFile}${suffix}`, csv);
256+
}
257+
258+
process.exit(0);
259+
};

workspaces/marketplace/packages/cli/src/commands/index.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,29 @@ export const registerCommands = (program: Command) => {
6363
program
6464
.command('verify')
6565
.description(
66-
'Verify a set of markplate entities. By default, it will read entities from the standard input',
66+
'Verify a set of marketplace entities. By default, it will read entities from the standard input',
6767
)
6868
.action(lazy(() => import('./verify').then(m => m.default)));
69+
70+
program
71+
.command('export-csv')
72+
.description('Export a folder of marketplace plugin YAMLs to a CSV file')
73+
.option(
74+
'-o, --output-file [path]',
75+
'Path to the output CSV file. By default, it will output to the standard output. When a file is specified, the "csv" file extension will be added automatically',
76+
)
77+
.option(
78+
'-p, --plugins-yaml-path [path]',
79+
'Path to the default plugins folder, containing marketplace plugin YAML files. Multiple paths can be provided, separated by commas',
80+
)
81+
.option(
82+
'-r, --recursive',
83+
'Recursively search for YAML files in each directory provided in plugins-yaml-path',
84+
)
85+
.option(
86+
'-t, --type [type]',
87+
'The type of CSV to export. Can be one of: "plugin", "package", or "all". "all" will generate two files.',
88+
'all',
89+
)
90+
.action(lazy(() => import('./export-csv').then(m => m.default)));
6991
};

0 commit comments

Comments
 (0)