diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..09ca51a Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 5d91d99..b78fa67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store pkg.macos pkg.exe pkg.linux diff --git a/README.md b/README.md index 4156ad2..cf75b5e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ A tool for managing the packages (repositories) on which your application depend # Run directly deno run pkg.ts --config=config.json +# Run directly from remote +deno run https://raw.githubusercontent.com/adamjosefus/pkg/main/pkg.ts --config=config.json + # Run bundled deno run pkg.bundled.js --config=config.json @@ -24,22 +27,6 @@ deno run pkg.bundled.js --config=config.json ``` -## Config - -```json -{ - "https://github.com/adamjosefus/pkg.git": { - "dest": "../packages", - }, - "": { - "dest": "", - "branch": "" - } -} -``` - ---- - ## Help @@ -67,6 +54,62 @@ deno run pkg.ts --help ``` +## Config + +### Schema + +```js +{ + "destination": "", // optional + "variables": { + "": "", + "": { + "from": "" + } + }, // optional + "repositories": { + "": { + "destination": "" // optional + "name": "" // optional + "tag": "" // optional + "variables": { + "": "", + "": { + "from": "" + } + } // optional + } + } +} +``` + +### Example +```json +{ + "destination": "./packages", + "variables": { + "ACCESS_TOKEN": { + "from": "./sercet.txt" + } + }, + "repositories": { + "https://github.com/foo.git": [ + { + "name": "Foo_v1", + "tag": "v1.0.0" + }, + { + "name": "Foo_v2", + "tag": "v2.1.1" + } + ], + "https://username:${ACCESS_TOKEN}@dev.azure.com/bar": { + "name": "Bar" + } + } +} +``` + --- diff --git a/lib/main.ts b/lib/main.ts deleted file mode 100644 index 9109050..0000000 --- a/lib/main.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * @copyright Copyright (c) 2022 Adam Josefus - */ - - -import { join, basename, dirname, isAbsolute } from "https://deno.land/std@0.126.0/path/mod.ts"; -import { green, red, gray, bold } from "https://deno.land/std@0.126.0/fmt/colors.ts"; -import { Arguments, ValueException } from "https://deno.land/x/allo_arguments@v4.0.1/mod.ts"; -import { existsSync } from "./exists.ts"; - - -const successStyle = (s: string) => green(bold(s)); -const errorStyle = (s: string) => red(bold(s)); - - -type ConfigFileType = { - [repository: string]: { - name?: string, - dest?: string, - branch?: string, - } | boolean, -} - - -type PackageListType = Array<{ - git: string, - name: string, - path: string, - repository: string, - branch: string | null, -}> - - -const getArguments = () => { - const args = new Arguments( - { - name: 'config, c', - description: `Path to the package configuration file.`, - convertor: (path: string | null | false): string => { - if (path === null || path === false) throw new ValueException(`Cesta na konfigurační soubor není platná.`); - path = join(Deno.cwd(), path) as string; - - if (existsSync(path) === false) { - throw new ValueException(`--config=${path}\nThe file does not exist.`); - } - - try { - JSON.parse(Deno.readTextFileSync(path)); - } catch (_err) { - throw new ValueException(`The JSON of the configuration file is corrupted.`); - } - - return path; - }, - default: "pkg.json" - }, - { - name: 'install, i', - description: `Installs packages from the configuration file.`, - convertor: (v: string | boolean) => v === true || v === 'true', - default: false - }, - { - name: 'delete, uninstall', - description: `Deletes packages according to the configuration file.`, - convertor: (v: string | boolean) => v === true || v === 'true', - default: false - } - ); - - - if (args.shouldHelp()) { - args.triggerHelpException(); - } - - return { - delete: args.get('delete'), - install: args.get('install'), - config: args.get('config'), - } -} - - -const parseConfig = (json: string, root: string, separateGitRoot: string): PackageListType => { - const list: PackageListType = []; - const data = JSON.parse(json) as ConfigFileType; - - for (const repository in data) { - const value = data[repository]; - if (value === false) break; - const options = value === true ? {} : value; - - const originalName = basename(repository, '.git'); - const name = options.name ?? originalName; - - const path = (d => { - const p = join(d, name); - - return isAbsolute(p) ? p : join(root, p); - })(options.dest ?? './'); - - const branch = options.branch ?? null; - - list.push({ - git: join(separateGitRoot, originalName), - name, - path, - repository, - branch, - }); - } - - return list; -} - - -// deno-lint-ignore no-explicit-any -const runCommand = async (...cmd: any[]) => { - const process = Deno.run({ - cmd, - stdout: "piped", - stderr: "piped", - }); - - const status = await process.status(); - - const output = await (async (ok) => { - if (ok) return await process.output() - else return await process.stderrOutput() - })(status.success); - - const decoder = new TextDecoder(); - - return { - success: status.success, - message: decoder.decode(output) - } -} - - -function padRight(s: string, all: string[]): string { - const length = all.reduce((a, b) => Math.max(a, b.length), 0) + 5; - - return s.padEnd(length, '.'); -} - - -const installPackage = async (list: PackageListType, separateGitRoot: string) => { - function createTask(git: string, path: string, reference: string, branch: string | null) { - const task: string[] = []; - - task.push('git', 'clone'); - task.push('--depth', '1'); - - if (branch) { - task.push('--branch', branch); - } - - task.push('--separate-git-dir', git); - - task.push(reference); - task.push(path); - task.push('--single-branch'); - - return task; - } - - - function printTask(name: string, names: string[], success: boolean, message: string) { - console.log([ - `> ${padRight(name, names)}`, - success ? successStyle('install OK') : errorStyle('install FAILED'), - ].join('')); - - if (message.trim() !== '') { - console.log(gray(`>> ${message}`)); - } - } - - Deno.mkdirSync(separateGitRoot); - - // Run tasks - for (const item of list) { - const cmd = createTask(item.git, item.path, item.repository, item.branch); - const p = await runCommand(...cmd); - - printTask(item.repository, list.map(x => x.repository), p.success, p.message); - } - - Deno.removeSync(separateGitRoot, { recursive: true }); -} - - -const deletePackage = (list: PackageListType) => { - function printTask(name: string, names: string[], success: boolean, message: string) { - console.log([ - `> ${padRight(name, names)}`, - success ? successStyle('delete OK') : errorStyle('delete FAILED'), - ].join('')); - - if (message.trim() !== '') { - console.log(gray(`>> ${message}`)); - } - } - - // Run tasks - for (const item of list) { - let success: boolean; - let message: string; - - try { - Deno.removeSync(item.path, { recursive: true }); - success = true; - message = ''; - } catch (error) { - success = false; - message = error.toString(); - } - - printTask(item.repository, list.map(x => x.repository), success, message); - } -} - - -const run = async () => { - const args = getArguments(); - - const root = dirname(args.config); - const separateGitRoot = join(root, './.pkg'); - - const configJson = Deno.readTextFileSync(args.config); - const config = parseConfig(configJson, root, separateGitRoot); - - if (args.delete) { - console.log('\n'); - - const yes = 'y'; - const no = 'n'; - const decision = prompt(`Are you sure you want to delete? (${yes}/${no})`, 'n'); - - if (decision === yes) await deletePackage(config); - - console.log('\n'); - } - - - if (args.install) { - console.log('\n'); - await installPackage(config, separateGitRoot); - console.log('\n'); - } -} - - -export const pkg = async () => { - try { - await run(); - } catch (error) { - if (!(Arguments.isArgumentException(error))) throw error; - } -}; diff --git a/libs/.DS_Store b/libs/.DS_Store new file mode 100644 index 0000000..acb4aa0 Binary files /dev/null and b/libs/.DS_Store differ diff --git a/lib/exists.ts b/libs/helpers/exists.ts similarity index 100% rename from lib/exists.ts rename to libs/helpers/exists.ts diff --git a/libs/helpers/makeAbsolute.ts b/libs/helpers/makeAbsolute.ts new file mode 100644 index 0000000..8ad8cb3 --- /dev/null +++ b/libs/helpers/makeAbsolute.ts @@ -0,0 +1,15 @@ +/** + * @copyright Copyright (c) 2022 Adam Josefus + */ + + +import { join, isAbsolute } from "https://deno.land/std@0.126.0/path/mod.ts"; + + +export function makeAbsolute(root: string, path: string): string { + if (isAbsolute(path)) { + return path; + } + + return join(root, path); +} \ No newline at end of file diff --git a/libs/helpers/runCommand.ts b/libs/helpers/runCommand.ts new file mode 100644 index 0000000..952c69a --- /dev/null +++ b/libs/helpers/runCommand.ts @@ -0,0 +1,28 @@ +/** + * @copyright Copyright (c) 2022 Adam Josefus + */ + + +export async function runCommand(...cmd: string[]) { + const process = Deno.run({ + cmd, + stdout: "piped", + stderr: "piped", + }); + + const status = await process.status(); + + const output = await (async (ok) => { + if (ok) return await process.output() + else return await process.stderrOutput() + })(status.success); + + process.close(); + + const decoder = new TextDecoder(); + + return { + success: status.success, + message: decoder.decode(output) + } +} diff --git a/libs/helpers/styles.ts b/libs/helpers/styles.ts new file mode 100644 index 0000000..7220726 --- /dev/null +++ b/libs/helpers/styles.ts @@ -0,0 +1,26 @@ +/** + * @copyright Copyright (c) 2022 Adam Josefus + */ + + +import { green, red, yellow, gray, bold } from "https://deno.land/std@0.126.0/fmt/colors.ts"; + + +export function success(s: string) { + return green(bold(s)); +} + + +export function error(s: string) { + return red(bold(s)); +} + + +export function warning(s: string) { + return yellow(bold(s)); +} + + +export function note(s: string) { + return gray(s); +} diff --git a/libs/main.ts b/libs/main.ts new file mode 100644 index 0000000..e74c2a2 --- /dev/null +++ b/libs/main.ts @@ -0,0 +1,110 @@ +/** + * @copyright Copyright (c) 2022 Adam Josefus + */ + + +import { join, dirname } from "https://deno.land/std@0.126.0/path/mod.ts"; +import { Arguments, ValueException } from "https://deno.land/x/allo_arguments@v4.0.1/mod.ts"; +import { existsSync } from "./helpers/exists.ts"; +import { parseConfig } from "./model/parseConfig.ts"; +import { installPackages } from "./model/installPackages.ts"; +import { deletePackages } from "./model/deletePackages.ts"; + + +const getArguments = () => { + const args = new Arguments( + { + name: 'config, c', + description: `Path to the package configuration file.`, + convertor: (path: string | null | false): string => { + if (path === null || path === false) throw new ValueException(`Cesta na konfigurační soubor není platná.`); + path = join(Deno.cwd(), path) as string; + + if (existsSync(path) === false) { + throw new ValueException(`--config=${path}\nThe file does not exist.`); + } + + try { + JSON.parse(Deno.readTextFileSync(path)); + } catch (_err) { + throw new ValueException(`The JSON of the configuration file is corrupted.`); + } + + return path; + }, + default: "pkg.json" + }, + { + name: 'install, i', + description: `Installs packages from the configuration file.`, + convertor: (v: string | boolean) => v === true || v === 'true', + default: false + }, + { + name: 'delete, uninstall', + description: `Deletes packages according to the configuration file.`, + convertor: (v: string | boolean) => v === true || v === 'true', + default: false + }, + { + name: 'force', + description: `If true, the script will not ask for confirmation.`, + convertor: (v: string | boolean) => v === true || v === 'true', + default: false + } + ); + + if (args.shouldHelp()) { + args.triggerHelpException(); + } + + return { + config: args.get('config'), + delete: args.get('delete'), + install: args.get('install'), + force: args.get('force'), + } +} + + +const run = async () => { + const args = getArguments(); + + const configRoot = dirname(args.config); + const separateGitRoot = join(configRoot, './.pkg'); + + const configJson = Deno.readTextFileSync(args.config); + const config = parseConfig(configJson, configRoot, separateGitRoot); + + if (args.delete) { + console.log('\n'); + + let confirmation = false; + if (!args.force) { + const yes = 'y'; + const no = 'n'; + + confirmation = prompt(`Are you sure you want to delete? (${yes}/${no})`, 'n') === yes; + } + + if (confirmation || args.force) await deletePackages(config); + + console.log('\n'); + } + + + if (args.install) { + console.log('\n'); + await installPackages(config, separateGitRoot); + console.log('\n'); + } +} + + +export const pkg = async () => { + try { + await run(); + } catch (error) { + if (!(Arguments.isArgumentException(error))) throw error; + } +}; diff --git a/libs/model/deletePackages.ts b/libs/model/deletePackages.ts new file mode 100644 index 0000000..060a273 --- /dev/null +++ b/libs/model/deletePackages.ts @@ -0,0 +1,42 @@ +/** + * @copyright Copyright (c) 2022 Adam Josefus + */ + + +import { ConfigType } from "../types/ConfigType.ts"; +import * as styles from "../helpers/styles.ts"; + + +function print(name: string, columnLength: number, success: boolean, message: string) { + console.log( + `> ${name.padEnd(columnLength)}`, + success + ? styles.success('delete OK') + : styles.error('delete FAILED'), + ); + + if (message.trim() !== '') { + console.log(styles.note(`>> ${message}`)); + } +} + + +export async function deletePackages(config: ConfigType): Promise { + const length = config.map(x => x.reference).reduce((a, b) => Math.max(a, b.length), 0); + + for (const item of config) { + let success: boolean; + let message: string; + + try { + await Deno.remove(item.destinationDir, { recursive: true }); + success = true; + message = ''; + } catch (error) { + success = false; + message = error.toString(); + } + + print(item.displayReference, length + 5, success, message); + } +} diff --git a/libs/model/installPackages.ts b/libs/model/installPackages.ts new file mode 100644 index 0000000..75f816c --- /dev/null +++ b/libs/model/installPackages.ts @@ -0,0 +1,57 @@ +/** + * @copyright Copyright (c) 2022 Adam Josefus + */ + + +import { ConfigType } from "../types/ConfigType.ts"; +import { runCommand } from "../helpers/runCommand.ts"; +import * as styles from "../helpers/styles.ts"; + + +function createTask(reference: string, tag: string | null, destinationDir: string, separatedGitDir: string): readonly string[] { + const task: string[] = []; + + task.push('git', 'clone'); + task.push('--depth', '1'); + + if (tag) task.push('--branch', tag); + + task.push('--separate-git-dir', separatedGitDir); + + task.push(reference); + task.push(destinationDir); + task.push('--single-branch'); + + return task; +} + + +function print(name: string, columnLength: number, success: boolean, message: string) { + console.log( + `> ${name.padEnd(columnLength)}`, + success + ? styles.success('install OK') + : styles.error('install FAILED'), + ); + + if (message.trim() !== '') { + console.log(styles.note(`>> ${message}`)); + } +} + + +export async function installPackages(config: ConfigType, separateGitRoot: string): Promise { + // TODO: Change to temp directory + Deno.mkdirSync(separateGitRoot); + + const length = config.map(x => x.reference).reduce((a, b) => Math.max(a, b.length), 0); + + for (const item of config) { + const cmd = createTask(item.reference, item.tag, item.destinationDir, item.separatedGitDir); + const p = await runCommand(...cmd); + + print(item.displayReference, length + 5, p.success, p.message); + } + + Deno.removeSync(separateGitRoot, { recursive: true }); +} diff --git a/libs/model/parseConfig.ts b/libs/model/parseConfig.ts new file mode 100644 index 0000000..05e7ae0 --- /dev/null +++ b/libs/model/parseConfig.ts @@ -0,0 +1,141 @@ +/** + * @copyright Copyright (c) 2022 Adam Josefus + */ + + +import { join, basename } from "https://deno.land/std@0.126.0/path/mod.ts"; +import { type ConfigSchema, type VariableDeclarationsType } from "../types/ConfigSchema.ts"; +import { type ConfigType } from "../types/ConfigType.ts"; +import { makeAbsolute } from "../helpers/makeAbsolute.ts"; +import { variableFilters } from "./variableFilters.ts"; +import { VariableStoreType } from "../types/VariableStoreType.ts"; + + +const regex = { + variableReplacer: /\$\{(?[A-Z_][A-Z0-9_]*)(\|(?.+?))?\}/g, + authenticationReplacer: /^(?https?:\/\/)(?.+?)\:(?.+?)@/i +} + + +/** + * Parse config file and return a config object. + * + * @param json Content of the config file. + * @param configRoot Folder where the config file is located. + * @param separateGitRoot Meta folder where the git repository is located. + * @returns + */ +export function parseConfig(json: string, configRoot: string, separateGitRoot: string): ConfigType { + const config: ConfigType = []; + const data = JSON.parse(json) as ConfigSchema; + const commonVars = crateVariables(configRoot, data.variables ?? {}); + const repositories = data.repositories ?? {}; + + for (const reference in repositories) { + // Normalize reference + const settingsArr = (v => { + if (v === false || v === null) return null; + if (v === true) return [{}]; + + return Array.isArray(v) ? v : [v]; + })(repositories[reference]); + + // Skip if no settings + if (settingsArr === null) break; + + for (const settings of settingsArr) { + const localVars = crateVariables(configRoot, settings.variables ?? {}); + const getVariable = createVariableStore(localVars, commonVars); + + const remoteName = basename(reference, '.git'); + const localName = settings.name ?? remoteName; + const destinationDir = (d => { + return makeAbsolute(configRoot, join(d, localName)); + })(settings.destination ?? data.destination ?? './'); + + const separatedGitDir = join(separateGitRoot, remoteName); + const tag = settings.tag ?? null; + + config.push({ + reference: apllyVariables(reference, getVariable), + displayReference: createDisplayReference(apllyVariables(reference, getVariable)), + tag: tag ? apllyVariables(tag, getVariable) : null, + name: apllyVariables(localName, getVariable), + destinationDir: apllyVariables(destinationDir, getVariable), + separatedGitDir: apllyVariables(separatedGitDir, getVariable), + }); + } + } + + return config; +} + + +/** + * Transform a reference to a displayable reference. This is useful for hiding authentication information. + */ +function createDisplayReference(s: string): string { + const authenticationReplacer = /^(?https?:\/\/)(?.+?)\:(?.+?)@/i; + + return s.replace(authenticationReplacer, (_match, protocol, username, _password) => { + return `${protocol}${username}:●●●●●@`; + }); +} + + +/** + * Create a map of variables from config declarations. + * @param root Folder for relative paths. + * @param declarations + * @returns + */ +function crateVariables(root: string, declarations: VariableDeclarationsType): Map { + const variables = new Map(); + + for (const [name, value] of Object.entries(declarations)) { + // TODO: Check if name is valid + + if (typeof value === "string") { + variables.set(name, value); + } else { + const path = makeAbsolute(root, value.from); + const content = Deno.readTextFileSync(path); + + variables.set(name, content); + } + } + + return variables; +} + + +function createVariableStore(...declarations: Map[]): VariableStoreType { + return (name: string) => { + for (const declaration of declarations) { + if (declaration.has(name)) return declaration.get(name); + } + + return Deno.env.get(name); + } +} + + +/** + * Apply variables to a string. + */ +function apllyVariables(s: string, variableStore: VariableStoreType): string { + regex.variableReplacer.lastIndex = 0; + return s.replace(regex.variableReplacer, (_match, _g1, _g2, _g3, _g4, _g5, groups) => { + const { name, filter } = groups; + + const value = variableStore(name); + + if (value === undefined) return `\$${name}`; + if (filter === undefined) return value; + + const filterFunc = variableFilters.get(filter); + if (filterFunc) return filterFunc(value); + + return value; + }); +} diff --git a/libs/model/variableFilters.ts b/libs/model/variableFilters.ts new file mode 100644 index 0000000..80b34d5 --- /dev/null +++ b/libs/model/variableFilters.ts @@ -0,0 +1,4 @@ +export const variableFilters: Map string> = new Map(); + +variableFilters.set("encodeUri", (s: string) => encodeURI(s)); +variableFilters.set("encodeUriComponent", (s: string) => encodeURIComponent(s)); diff --git a/libs/types/ConfigSchema.ts b/libs/types/ConfigSchema.ts new file mode 100644 index 0000000..62dc4f6 --- /dev/null +++ b/libs/types/ConfigSchema.ts @@ -0,0 +1,26 @@ +/** + * @copyright Copyright (c) 2022 Adam Josefus + */ + + +export type VariableDeclarationsType = Record; + + +type PackageType = { + destination?: string, + name?: string, + tag?: string, + variables?: VariableDeclarationsType, +}; + + +type PackageMapType = Record; + + +export type ConfigSchema = { + destination?: string, + variables?: VariableDeclarationsType, + repositories?: PackageMapType, +}; diff --git a/libs/types/ConfigType.ts b/libs/types/ConfigType.ts new file mode 100644 index 0000000..4c869d5 --- /dev/null +++ b/libs/types/ConfigType.ts @@ -0,0 +1,13 @@ +/** + * @copyright Copyright (c) 2022 Adam Josefus + */ + + +export type ConfigType = { + readonly reference: string; + readonly displayReference: string; + readonly tag: string | null; + readonly name: string, + readonly destinationDir: string, + readonly separatedGitDir: string, +}[]; diff --git a/libs/types/VariableStoreType.ts b/libs/types/VariableStoreType.ts new file mode 100644 index 0000000..466b608 --- /dev/null +++ b/libs/types/VariableStoreType.ts @@ -0,0 +1,6 @@ +/** + * @copyright Copyright (c) 2022 Adam Josefus + */ + + +export type VariableStoreType = (name: string) => string | undefined; diff --git a/pkg.ts b/pkg.ts index 5233802..3a80bfc 100644 --- a/pkg.ts +++ b/pkg.ts @@ -1,3 +1,3 @@ -import { pkg } from "./lib/main.ts"; +import { pkg } from "./libs/main.ts"; pkg(); \ No newline at end of file diff --git a/tests/parseConfig.test.ts b/tests/parseConfig.test.ts new file mode 100644 index 0000000..55cad66 --- /dev/null +++ b/tests/parseConfig.test.ts @@ -0,0 +1,148 @@ +import { assertEquals } from "https://deno.land/std@0.126.0/testing/asserts.ts"; +import { parseConfig } from "../libs/model/parseConfig.ts"; + + +Deno.test("parseConfig", () => { + const configRoot = '/packages'; + const separateGitRoot = '/meta'; + + + const exercises: { + label: string, + json: string, + expected: unknown, + }[] = [ + { + label: "Test 1", + json: `{}`, + expected: [], + }, + { + label: "Test 2", + json: `{ + "repositories": { + "https://github.com/my-repo.git": { + + } + } + }`, + expected: [ + { + destinationDir: "/packages/my-repo", + displayReference: "https://github.com/my-repo.git", + name: "my-repo", + reference: "https://github.com/my-repo.git", + separatedGitDir: "/meta/my-repo", + tag: null, + }, + ], + }, + { + label: "Test 3", + json: `{ + "repositories": { + "https://github.com/my-repo.git": false + } + }`, + expected: [], + }, + { + label: "Test 4", + json: `{ + "repositories": { + "https://github.com/my-repo.git": null + } + }`, + expected: [], + }, + { + label: "Test 5", + json: `{ + "repositories": { + "https://github.com/my-repo.git": true + } + }`, + expected: [ + { + destinationDir: "/packages/my-repo", + displayReference: "https://github.com/my-repo.git", + name: "my-repo", + reference: "https://github.com/my-repo.git", + separatedGitDir: "/meta/my-repo", + tag: null, + }, + ], + }, + { + label: "Test 6", + json: `{ + "destination": "./subdir", + "repositories": { + "https://github.com/my-repo.git": true + } + }`, + expected: [ + { + destinationDir: "/packages/subdir/my-repo", + displayReference: "https://github.com/my-repo.git", + name: "my-repo", + reference: "https://github.com/my-repo.git", + separatedGitDir: "/meta/my-repo", + tag: null, + }, + ], + }, + { + label: "Test 7", + json: `{ + "destination": "./subdir", + "repositories": { + "https://github.com/my-repo.git": { + "destination": "./subdir2" + } + } + }`, + expected: [ + { + destinationDir: "/packages/subdir2/my-repo", + displayReference: "https://github.com/my-repo.git", + name: "my-repo", + reference: "https://github.com/my-repo.git", + separatedGitDir: "/meta/my-repo", + tag: null, + }, + ], + }, + { + label: "Test 8", + json: `{ + "destination": "./subdir", + "variables": { + "MY_VAR": "my-value" + }, + "repositories": { + "https://github.com/my-repo.git": { + "destination": "./subdir2/${"${MY_VAR}"}" + } + } + }`, + expected: [ + { + destinationDir: "/packages/subdir2/my-value/my-repo", + displayReference: "https://github.com/my-repo.git", + name: "my-repo", + reference: "https://github.com/my-repo.git", + separatedGitDir: "/meta/my-repo", + tag: null, + }, + ], + }, + ]; + + + exercises.forEach(({ json, expected, label }) => { + const config = parseConfig(json, configRoot, separateGitRoot); + + assertEquals(config, expected, label); + }); +}); \ No newline at end of file