#!/usr/bin/env node import { lstat, readdir, readFile } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { parseDocument } from "yaml"; type CatalogType = "category" | "program"; type CatalogIndex = { type?: unknown; name?: unknown; order?: unknown; versions?: Array<{ version?: unknown; status?: unknown }>; }; type CatalogVersion = { version: string; status?: "current" | "archived"; }; type CatalogNode = { type: CatalogType; name: string; order: number; directory: string; versions: CatalogVersion[]; children: CatalogNode[]; }; type Options = { showPaths: boolean; showVersions: boolean; target: string; }; const collator = new Intl.Collator("ru", { numeric: true, sensitivity: "base", }); function usage(): void { console.log(`Usage: pnpm tree [options] [path] With no path, the script prints the complete tree from catalog. A path may point to a category directory, a program directory, or its index.yaml file. --versions show versions under each program --paths show relative directory paths --help show this help message`); } function parseArguments(args: string[]): Options { const targets: string[] = []; let showPaths = false; let showVersions = false; for (const argument of args) { if (argument === "--paths") { showPaths = true; } else if (argument === "--versions") { showVersions = true; } else if (argument === "--help" || argument === "-h") { usage(); process.exit(0); } else if (argument.startsWith("-")) { throw new Error(`Unknown option: ${argument}`); } else { targets.push(argument); } } if (targets.length > 1) { throw new Error("Only one path may be specified"); } return { showPaths, showVersions, target: targets[0] ?? "catalog", }; } function compareNodes(left: CatalogNode, right: CatalogNode): number { if (left.type !== right.type) { return left.type === "category" ? -1 : 1; } if (left.type === "category" && left.order !== right.order) { return left.order - right.order; } return collator.compare(left.name, right.name); } async function resolveDirectory(target: string): Promise { const absoluteTarget = path.resolve(target); const targetStat = await lstat(absoluteTarget); if (targetStat.isDirectory()) { return absoluteTarget; } if (targetStat.isFile() && path.basename(absoluteTarget) === "index.yaml") { return path.dirname(absoluteTarget); } throw new Error(`Expected a catalog directory or index.yaml file: ${target}`); } async function readCatalogNode(directory: string): Promise { const indexPath = path.join(directory, "index.yaml"); let source: string; try { source = await readFile(indexPath, "utf8"); } catch (error) { const reason = error instanceof Error ? error.message : String(error); throw new Error(`${indexPath}: failed to read index.yaml: ${reason}`); } const document = parseDocument(source, { prettyErrors: true }); if (document.errors.length > 0) { const errors = document.errors.map((error) => error.message).join("; "); throw new Error(`${indexPath}: ${errors}`); } const data = document.toJS() as CatalogIndex; if (data.type !== "category" && data.type !== "program") { throw new Error(`${indexPath}: unknown type`); } if (typeof data.name !== "string" || data.name.length === 0) { throw new Error(`${indexPath}: name is missing`); } if (data.type === "category" && data.order !== undefined && !Number.isInteger(data.order)) { throw new Error(`${indexPath}: category order must be an integer`); } const order = data.type === "category" && typeof data.order === "number" ? data.order : 0; const versions = data.type === "program" ? (data.versions ?? []).map((entry, index) => { if (typeof entry.version !== "string" || entry.version.length === 0) { throw new Error(`${indexPath}: invalid version at index ${index}`); } if ( entry.status !== undefined && entry.status !== "current" && entry.status !== "archived" ) { throw new Error(`${indexPath}: unknown status for version ${entry.version}`); } return { version: entry.version, status: entry.status } as CatalogVersion; }) : []; const children: CatalogNode[] = []; if (data.type === "category") { const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) { continue; } const childDirectory = path.join(directory, entry.name); try { const childIndexStat = await lstat(path.join(childDirectory, "index.yaml")); if (childIndexStat.isFile()) { children.push(await readCatalogNode(childDirectory)); } } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { throw error; } } } } children.sort(compareNodes); return { type: data.type, name: data.name, order, directory, versions, children }; } function formatNode(node: CatalogNode, showPaths: boolean): string { const categorySuffix = node.type === "category" ? "/" : ""; if (!showPaths) { return `${node.name}${categorySuffix}`; } const relativePath = path.relative(process.cwd(), node.directory) || "."; return `${node.name}${categorySuffix} [${relativePath}]`; } function printChildren( node: CatalogNode, prefix: string, options: Pick, ): void { const entries: Array<{ label: string; node?: CatalogNode }> = node.children.map( (child) => ({ label: formatNode(child, options.showPaths), node: child }), ); if (options.showVersions && node.type === "program") { entries.push( ...node.versions.map(({ version, status }) => ({ label: `version ${version}${status === undefined ? "" : ` [${status}]`}`, })), ); } entries.forEach((entry, index) => { const last = index === entries.length - 1; console.log(`${prefix}${last ? "└── " : "├── "}${entry.label}`); if (entry.node !== undefined) { printChildren(entry.node, `${prefix}${last ? " " : "│ "}`, options); } }); } async function main(): Promise { const options = parseArguments(process.argv.slice(2)); const directory = await resolveDirectory(options.target); const root = await readCatalogNode(directory); console.log(formatNode(root, options.showPaths)); printChildren(root, "", options); } try { await main(); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`Error: ${message}`); process.exitCode = 1; }