import { dirname } from "node:path"; import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { CatalogData, CatalogItem, CatalogProfile, ConstructPaths, JsonReadResult, LoadResult } from "./types.js"; import { describeJsonReadIssue, isObject, readJson, writeJson } from "./json.js"; import { getPaths } from "./paths.js"; import { getPackages } from "./project-settings.js"; export { isLocalPathSource, normalizeSourceForLibrary } from "./sources.js"; import { normalizeSourceForLibrary, packageSourceMatchValues } from "./sources.js"; export function parseCatalog(catalog: JsonReadResult): { data: CatalogData; warnings: string[] } { const warnings: string[] = []; if (catalog.state === "missing") return { data: { version: 1, items: [], profiles: [] }, warnings }; if (catalog.state === "invalid") { warnings.push(describeJsonReadIssue("Catalog", catalog)); return { data: { version: 1, items: [], profiles: [] }, warnings }; } if (!isObject(catalog.data)) { warnings.push("Catalog JSON is not an object."); return { data: { version: 1, items: [], profiles: [] }, warnings }; } if (catalog.data.version !== 1) warnings.push("Catalog version is missing or not 1; preserving only valid MVP package items."); if (!Array.isArray(catalog.data.items)) { warnings.push("Catalog items is missing or not an array."); return { data: { version: 1, items: [], profiles: [] }, warnings }; } const items: CatalogItem[] = []; for (const [index, item] of catalog.data.items.entries()) { if (!isObject(item)) { warnings.push(`Catalog item ${index} is not an object; ignored.`); continue; } if (item.kind !== "package") { warnings.push(`Catalog item ${index} is not kind=package; ignored for MVP.`); continue; } if (typeof item.id !== "string" || !item.id.trim()) { warnings.push(`Catalog item ${index} has no id; ignored.`); continue; } if (typeof item.source !== "string" || !item.source.trim()) { warnings.push(`Catalog item ${item.id} has no source; ignored.`); continue; } items.push({ ...item, id: item.id.trim(), name: typeof item.name === "string" && item.name.trim() ? item.name.trim() : undefined, kind: "package", source: item.source.trim(), description: typeof item.description === "string" && item.description.trim() ? item.description.trim() : undefined, }); } const profiles: CatalogProfile[] = []; const rawProfiles = catalog.data.profiles; if (rawProfiles !== undefined) { if (!Array.isArray(rawProfiles)) warnings.push("Catalog profiles is not an array; ignored."); else { for (const [index, profile] of rawProfiles.entries()) { if (!isObject(profile)) { warnings.push(`Catalog profile ${index} is not an object; ignored.`); continue; } if (typeof profile.id !== "string" || !profile.id.trim()) { warnings.push(`Catalog profile ${index} has no id; ignored.`); continue; } const profileItems = Array.isArray(profile.items) ? profile.items.filter((item): item is string => typeof item === "string" && item.trim().length > 0).map((item) => item.trim()) : []; const profileSources = Array.isArray(profile.sources) ? profile.sources.filter((source): source is string => typeof source === "string" && source.trim().length > 0).map((source) => source.trim()) : []; profiles.push({ ...profile, id: profile.id.trim(), name: typeof profile.name === "string" && profile.name.trim() ? profile.name.trim() : undefined, kind: "profile", items: profileItems, sources: profileSources, createdAt: typeof profile.createdAt === "string" ? profile.createdAt : undefined, updatedAt: typeof profile.updatedAt === "string" ? profile.updatedAt : undefined, }); } } } return { data: { version: 1, items, profiles }, warnings }; } export function deriveId(source: string): string { let candidate = source.trim().replace(/\/+$/, ""); if (candidate.startsWith("npm:")) { candidate = candidate.slice(4); const versionAt = candidate.lastIndexOf("@"); if (versionAt > 0) candidate = candidate.slice(0, versionAt); } else if ( candidate.startsWith("/") || candidate.startsWith("./") || candidate.startsWith("../") || candidate.startsWith("~") ) { candidate = candidate.split("/").filter(Boolean).at(-1) ?? candidate; } else { candidate = candidate .replace(/^git:/, "") .replace(/^https?:\/\//, "") .replace(/^ssh:\/\//, "") .replace(/\.git$/, ""); const refAt = candidate.lastIndexOf("@"); if (refAt > candidate.lastIndexOf("/")) candidate = candidate.slice(0, refAt); const parts = candidate.split(/[/:]/).filter(Boolean); candidate = parts.at(-1) ?? candidate; } const id = candidate .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 48); return id || "package"; } export function uniqueId(baseId: string, items: CatalogItem[]): string { const existing = new Set(items.map((item) => item.id)); if (!existing.has(baseId)) return baseId; for (let i = 2; i < 1000; i++) { const candidate = `${baseId}-${i}`; if (!existing.has(candidate)) return candidate; } return `${baseId}-${Date.now()}`; } export function findCatalogItem(items: CatalogItem[], query: string): CatalogItem | undefined { return items.find((item) => item.id === query || item.source === query || item.name === query); } export async function findCatalogItemForSource(items: CatalogItem[], source: string, baseDir: string): Promise { const sourceMatches = new Set(await packageSourceMatchValues(source, baseDir)); for (const item of items) { if (item.source === source || sourceMatches.has(item.source)) return item; const itemMatches = await packageSourceMatchValues(item.source, baseDir); if (itemMatches.some((candidate) => sourceMatches.has(candidate))) return item; } return undefined; } export function formatCatalogItem(item: CatalogItem): string { const name = item.name ? ` (${item.name})` : ""; const description = item.description ? ` — ${item.description}` : ""; return `- ${item.id}${name}: ${item.source}${description}`; } export async function loadCatalog(ctx: Pick): Promise<{ paths: ConstructPaths; read: JsonReadResult; catalog: CatalogData; warnings: string[] }> { const paths = await getPaths(ctx); const read = await readJson(paths.userCatalogPath); const { data, warnings } = parseCatalog(read); return { paths, read, catalog: data, warnings }; } export async function packageSourcesFromSettings(settingsPath: string): Promise { const settings = await readJson(settingsPath); const baseDir = dirname(settingsPath); const packages = getPackages(settings).filter((pkg) => pkg.form !== "invalid" && pkg.enabled && pkg.source.trim()); const sources: string[] = []; for (const pkg of packages) { sources.push(await normalizeSourceForLibrary(pkg.source, baseDir)); } return sources; } export async function addSourcesToCatalog( ctx: Pick, sources: string[], prewrite?: () => Promise, ): Promise { const paths = await getPaths(ctx); const catalogRead = await readJson(paths.userCatalogPath); if (catalogRead.state === "invalid") { return { added: [], alreadyKnown: 0, warnings: [`Skipped Construct library load because ${describeJsonReadIssue("catalog", catalogRead)}`] }; } const { data: catalog, warnings } = parseCatalog(catalogRead); if (catalogRead.state === "ok" && warnings.length > 0) { return { added: [], alreadyKnown: 0, warnings: [`Skipped Construct library load because catalog has warnings; fix ${paths.userCatalogPath} first.`, ...warnings] }; } const baseDir = dirname(paths.projectSettingsPath); const existingSources = new Set(); for (const item of catalog.items) { for (const match of await packageSourceMatchValues(item.source, baseDir)) existingSources.add(match); } const nextItems = [...catalog.items]; const added: CatalogItem[] = []; let alreadyKnown = 0; for (const source of sources) { if (!source) continue; const matches = await packageSourceMatchValues(source, baseDir); if (matches.some((match) => existingSources.has(match))) { alreadyKnown += 1; continue; } const item: CatalogItem = { id: uniqueId(deriveId(source), nextItems), kind: "package", source }; nextItems.push(item); added.push(item); for (const match of matches) existingSources.add(match); } if (added.length > 0) { await prewrite?.(); await writeJson(paths.userCatalogPath, { ...catalog, version: 1, items: nextItems.sort((a, b) => a.id.localeCompare(b.id)) }); } return { added, alreadyKnown, warnings }; }