Skip to content

Commit 740b92b

Browse files
committed
feat(experimental): init li cli for adding data to li
1 parent 363f4ae commit 740b92b

File tree

9 files changed

+1792
-0
lines changed

9 files changed

+1792
-0
lines changed

experimental/li-cli/package-lock.json

Lines changed: 1390 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

experimental/li-cli/package.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "@finos/git-proxy-li-cli",
3+
"version": "0.0.1",
4+
"author": "git-proxy contributors",
5+
"license": "Apache-2.0",
6+
"files": [
7+
"dist"
8+
],
9+
"scripts": {
10+
"start": "ts-node src/cli.ts",
11+
"build": "rimraf dist && tsc --project tsconfig.publish.json && tsc-alias && chmod u+x ./dist/cli.js",
12+
"type-check": "tsc --noEmit",
13+
"test": "jest --forceExit --detectOpenHandles"
14+
},
15+
"dependencies": {
16+
"@inquirer/prompts": "^7.3.1",
17+
"yaml": "^2.7.0",
18+
"yargs": "^17.7.2",
19+
"zod": "^3.24.1"
20+
},
21+
"devDependencies": {
22+
"@types/node": "^22.10.9",
23+
"@types/yargs": "^17.0.33",
24+
"ts-node": "^10.9.2",
25+
"tsc-alias": "^1.8.10",
26+
"typescript": "^5.7.3"
27+
}
28+
}

experimental/li-cli/src/cli.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/usr/bin/env node
2+
3+
import yargs from 'yargs';
4+
import { hideBin } from 'yargs/helpers';
5+
import addLicenseCMD from './cmds/add-license';
6+
import process from 'node:process';
7+
8+
yargs(hideBin(process.argv))
9+
// .command(
10+
// 'serve [port]',
11+
// 'start the server',
12+
// (yargs) => {
13+
// return yargs.positional('port', {
14+
// describe: 'port to bind on',
15+
// default: 5000,
16+
// });
17+
// },
18+
// (argv) => {
19+
// addLicenseCMD();
20+
// },
21+
// )
22+
.option('li-url', {
23+
type: 'string',
24+
describe: 'The url of the license inventory instance',
25+
})
26+
.command(
27+
'add-license [SPDXID]',
28+
'',
29+
(yargs) =>
30+
yargs
31+
.positional('SPDXID', {
32+
type: 'string',
33+
describe: 'ID of license',
34+
})
35+
.option('require-cal', {
36+
type: 'boolean',
37+
default: false,
38+
describe: 'require successful collection of info from Choose A License',
39+
})
40+
.demandOption('li-url'),
41+
async (argv) => {
42+
try {
43+
await addLicenseCMD(argv['li-url'], {
44+
spdxID: argv.SPDXID,
45+
requireCal: argv['require-cal'],
46+
});
47+
} catch (e) {
48+
process.exit(1);
49+
}
50+
},
51+
)
52+
.option('verbose', {
53+
alias: 'v',
54+
type: 'boolean',
55+
description: 'Run with verbose logging',
56+
})
57+
.demandCommand()
58+
.help()
59+
.parse();
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { setTimeout } from 'node:timers/promises';
2+
import { search } from '@inquirer/prompts';
3+
import { getLicenseList, LicensesMap } from '../lib/spdx';
4+
import { CalInfo, InventoryLicense, pushLicense } from '../lib/inventory';
5+
import { getCALData } from '../lib/chooseALicense';
6+
7+
// const answer = await input({ message: 'Enter your name' });
8+
9+
type Option = {
10+
name: string;
11+
value: string;
12+
};
13+
14+
type AddLicenseCMDOptions = {
15+
spdxID?: string;
16+
requireCal?: boolean;
17+
};
18+
19+
async function promptForSPDXID(licenseList: LicensesMap): Promise<string> {
20+
const options: Option[] = [];
21+
licenseList.forEach(({ licenseId }, k) => {
22+
options.push({ name: licenseId, value: k });
23+
});
24+
const selectedLicenseID = await search({
25+
message: 'Select a license',
26+
source: async (input, { signal }) => {
27+
await setTimeout(300);
28+
if (signal.aborted) return options;
29+
if (typeof input !== 'string' || input.length < 1) {
30+
return options;
31+
}
32+
// TODO: itterate over itterable rather than convert to array then map
33+
// TODO: fuzzy match
34+
return options.filter((o) => o.value.toLowerCase().startsWith(input?.toLowerCase()));
35+
},
36+
});
37+
return selectedLicenseID;
38+
}
39+
40+
async function addLicenseCMD(liURL: string, options?: AddLicenseCMDOptions) {
41+
console.info('fetching license list from `spdx.org`');
42+
const licenseList = await getLicenseList();
43+
console.info('done fetching');
44+
45+
const selectedLicenseID = options?.spdxID ?? (await promptForSPDXID(licenseList));
46+
47+
const selectedLicense = licenseList.get(selectedLicenseID);
48+
if (typeof selectedLicense !== 'object') {
49+
console.error("license doesn't exist in list fetched from `spdx.org`");
50+
throw new Error('missing license');
51+
}
52+
53+
console.info(`fetching Choose A License info for license \`${selectedLicense.licenseId}\``);
54+
55+
let chooseALicenseInfo: CalInfo | undefined = undefined;
56+
try {
57+
const cal = await getCALData(selectedLicense.licenseId.toLowerCase());
58+
chooseALicenseInfo = cal;
59+
} catch (e) {
60+
console.log('failed to get info from Choose A License');
61+
if (options?.requireCal) {
62+
throw new Error('forced the need for CAL data');
63+
}
64+
}
65+
66+
const licenseData: InventoryLicense = {
67+
name: selectedLicense.name,
68+
spdxID: selectedLicense.licenseId,
69+
chooseALicenseInfo,
70+
};
71+
72+
let licenseID: string;
73+
try {
74+
const id = await pushLicense(liURL, licenseData);
75+
licenseID = id;
76+
} catch (e) {
77+
console.error('failed to submit license data');
78+
throw new Error('failed to submit');
79+
}
80+
81+
console.log(`License \`${selectedLicense.licenseId}\` added to inventory as \`${licenseID}\``);
82+
}
83+
84+
export default addLicenseCMD;
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import YAML from 'yaml';
2+
import z from 'zod';
3+
import { CalInfo } from './inventory';
4+
5+
const chooseALicenseSchema = z.object({
6+
conditions: z.string().array(),
7+
limitations: z.string().array(),
8+
permissions: z.string().array(),
9+
});
10+
11+
type ChooseALicenseData = z.infer<typeof chooseALicenseSchema>;
12+
13+
function extractPreamble(text: string): string {
14+
const lines = text.split('\n'); // Split into lines
15+
let result: string[] = [];
16+
let insideBlock = false;
17+
18+
for (const line of lines) {
19+
const trimmed = line.trim(); // Remove extra spaces
20+
21+
if (trimmed === '---') {
22+
if (insideBlock) break; // Stop at the second `---`
23+
insideBlock = true; // Start capturing after first `---`
24+
continue;
25+
}
26+
27+
if (insideBlock) {
28+
result.push(line); // Collect valid lines
29+
}
30+
}
31+
32+
return result.join('\n').trim(); // Join and clean up
33+
}
34+
35+
export const getCALData = async (spdxID: string): Promise<CalInfo | undefined> => {
36+
// TODO: add path customization
37+
38+
const req = await fetch(
39+
`https://raw.githubusercontent.com/github/choosealicense.com/refs/heads/gh-pages/_licenses/${spdxID}.txt`,
40+
);
41+
const data = await req.text();
42+
const preamble = extractPreamble(data);
43+
44+
const parsedPreamble = YAML.parse(preamble);
45+
46+
const { data: calData, error } = chooseALicenseSchema.safeParse(parsedPreamble);
47+
if (error) {
48+
throw new Error("couldn't process data", { cause: error });
49+
}
50+
51+
// TODO: define the keys and children we recognise, raise issues if new ones are added, automatically format test to object keys + booleans
52+
const processedData: CalInfo = {
53+
...(calData.permissions.length > 0 && {
54+
permissions: {
55+
...(calData.permissions.includes('commercial-use') && { commercialUse: true }),
56+
...(calData.permissions.includes('modifications') && { modifications: true }),
57+
...(calData.permissions.includes('distribution') && { distribution: true }),
58+
...(calData.permissions.includes('private-use') && { privateUse: true }),
59+
...(calData.permissions.includes('patent-use') && { patentUse: true }),
60+
},
61+
}),
62+
...(calData.conditions.length > 0 && {
63+
conditions: {
64+
...(calData.conditions.includes('include-copyright') && { includeCopyright: true }),
65+
...(calData.conditions.includes('include-copyright--source') && {
66+
includeCopyrightSource: true,
67+
}),
68+
...(calData.conditions.includes('document-changes') && { documentChanges: true }),
69+
...(calData.conditions.includes('disclose-source') && { discloseSource: true }),
70+
...(calData.conditions.includes('network-use-disclose') && { networkUseDisclose: true }),
71+
...(calData.conditions.includes('same-license') && { sameLicense: true }),
72+
...(calData.conditions.includes('same-license--file') && { sameLicenseFile: true }),
73+
...(calData.conditions.includes('same-license--library') && { sameLicenseLibrary: true }),
74+
},
75+
}),
76+
...(calData.limitations.length > 0 && {
77+
limitations: {
78+
...(calData.limitations.includes('trademark-use') && { trademarkUse: true }),
79+
...(calData.limitations.includes('liability') && { liability: true }),
80+
...(calData.limitations.includes('patent-use') && { patentUse: true }),
81+
...(calData.limitations.includes('warranty') && { warranty: true }),
82+
},
83+
}),
84+
};
85+
return Object.keys(processedData).length > 0 ? processedData : undefined;
86+
};
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { z } from 'zod';
2+
3+
export type CalInfo = {
4+
permissions?: {
5+
commercialUse?: boolean;
6+
modifications?: boolean;
7+
distribution?: boolean;
8+
privateUse?: boolean;
9+
patentUse?: boolean;
10+
};
11+
conditions?: {
12+
includeCopyright?: boolean;
13+
includeCopyrightSource?: boolean;
14+
documentChanges?: boolean;
15+
discloseSource?: boolean;
16+
networkUseDisclose?: boolean;
17+
sameLicense?: boolean;
18+
sameLicenseFile?: boolean;
19+
sameLicenseLibrary?: boolean;
20+
};
21+
limitations?: {
22+
trademarkUse?: boolean;
23+
liability?: boolean;
24+
patentUse?: boolean;
25+
warranty?: boolean;
26+
};
27+
};
28+
29+
export type InventoryLicense = {
30+
name: string;
31+
spdxID?: string;
32+
chooseALicenseInfo?: CalInfo;
33+
};
34+
35+
const pushLicenseSchema = z.object({
36+
id: z.string().uuid(),
37+
});
38+
39+
export async function pushLicense(liURL: string, data: InventoryLicense): Promise<string> {
40+
const path = '/api/v0/licenses/';
41+
const res = await fetch(liURL + path, {
42+
method: 'POST',
43+
headers: {
44+
Accept: 'application/json',
45+
'Content-Type': 'application/json',
46+
},
47+
body: JSON.stringify(data),
48+
});
49+
if (!res.ok) {
50+
throw new Error(await res.text());
51+
}
52+
const resObj = await res.json();
53+
const { data: resData, error } = pushLicenseSchema.safeParse(resObj);
54+
if (error) {
55+
throw new Error("couldn't process data", { cause: error });
56+
}
57+
return resData.id;
58+
}

experimental/li-cli/src/lib/spdx.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import z from 'zod';
2+
3+
const license = z.object({
4+
detailsUrl: z.string().url(),
5+
name: z.string(),
6+
licenseId: z.string(),
7+
});
8+
9+
export type License = z.infer<typeof license>;
10+
export type LicensesMap = Map<string, License>;
11+
12+
const licenses = z.object({
13+
licenseListVersion: z.string(),
14+
licenses: license.array(),
15+
});
16+
17+
export const getLicenseList = async () => {
18+
// https://spdx.org/licenses/licenses.json
19+
const req = await fetch('https://spdx.org/licenses/licenses.json');
20+
const data = await req.json();
21+
const { data: parsed, error } = licenses.safeParse(data);
22+
if (error) {
23+
throw new Error("couldn't get license list", { cause: error });
24+
}
25+
26+
const licenseMap = new Map<string, License>();
27+
parsed.licenses.forEach((license) => {
28+
licenseMap.set(license.licenseId.toLowerCase(), license);
29+
});
30+
31+
return licenseMap;
32+
};
33+
34+
export const getLicenseData = async (url: string) => {
35+
// https://spdx.org/licenses/licenses.json
36+
const req = await fetch(url);
37+
const data = await req.json();
38+
const { data: parsed, error } = licenses.safeParse(data);
39+
if (error) {
40+
throw new Error("couldn't get license list", { cause: error });
41+
}
42+
43+
const licenseMap = new Map<string, License>();
44+
parsed.licenses.forEach((license) => {
45+
licenseMap.set(license.licenseId, license);
46+
});
47+
48+
return licenseMap;
49+
};

0 commit comments

Comments
 (0)