diff --git a/packages/cli/commands/charm.ts b/packages/cli/commands/charm.ts index 817fe4293..6af4f05e7 100644 --- a/packages/cli/commands/charm.ts +++ b/packages/cli/commands/charm.ts @@ -3,7 +3,9 @@ import { Command, ValidationError } from "@cliffy/command"; import { applyCharmInput, CharmConfig, + formatViewTree, generateSpaceMap, + getCharmView, inspectCharm, linkCharms, listCharms, @@ -229,6 +231,33 @@ Recipe: ${charmData.recipeName || ""} render(output); }) + /* charm view */ + .command("view", "Display the rendered view for a charm") + .usage(charmUsage) + .example( + `ct charm view ${EX_ID} ${EX_COMP_CHARM}`, + `Display the view for charm "${RAW_EX_COMP.charm!}".`, + ) + .example( + `ct charm view ${EX_ID} ${EX_URL}`, + `Display the view for charm "${RAW_EX_COMP.charm!}".`, + ) + .option("-c,--charm ", "The target charm ID.") + .option("--json", "Output raw JSON data") + .action(async (options) => { + const charmConfig = parseCharmOptions(options); + const view = await getCharmView(charmConfig); + if (options.json) { + render(view ?? null, { json: true }); + return; + } + if (view) { + const tree = formatViewTree(view); + render(tree); + } else { + render(""); + } + }) /* charm link */ .command("link", "Link a field from one charm to another") .usage(spaceUsage) @@ -299,7 +328,7 @@ Recipe: ${charmData.recipeName || ""} .action(async (options) => { const spaceConfig = parseSpaceOptions(options); const format = options.format === "dot" ? MapFormat.DOT : MapFormat.ASCII; - + const map = await generateSpaceMap(spaceConfig, format); render(map); }); diff --git a/packages/cli/lib/charm.ts b/packages/cli/lib/charm.ts index 038ab4122..8fd846ffd 100644 --- a/packages/cli/lib/charm.ts +++ b/packages/cli/lib/charm.ts @@ -8,6 +8,7 @@ import { RecipeMeta, Runtime, RuntimeProgram, + UI, } from "@commontools/runner"; import { StorageManager } from "@commontools/runner/storage/cache"; import { @@ -404,16 +405,22 @@ function createShortId(id: string): string { function createCharmConnection( charm: { id: string; name?: string }, - details?: { name?: string; readingFrom: Array<{ id: string }>; readBy: Array<{ id: string }> }, + details?: { + name?: string; + readingFrom: Array<{ id: string }>; + readBy: Array<{ id: string }>; + }, ): CharmConnection { return { name: details?.name || charm.name || createShortId(charm.id), - readingFrom: details?.readingFrom.map(c => c.id) || [], - readBy: details?.readBy.map(c => c.id) || [], + readingFrom: details?.readingFrom.map((c) => c.id) || [], + readBy: details?.readBy.map((c) => c.id) || [], }; } -async function buildConnectionMap(config: SpaceConfig): Promise { +async function buildConnectionMap( + config: SpaceConfig, +): Promise { const charms = await listCharms(config); const connections: CharmConnectionMap = new Map(); @@ -424,7 +431,11 @@ async function buildConnectionMap(config: SpaceConfig): Promise - (b.readingFrom.length + b.readBy.length) - - (a.readingFrom.length + a.readBy.length) + ([, a], [, b]) => + (b.readingFrom.length + b.readBy.length) - + (a.readingFrom.length + a.readBy.length), ); for (const [id, info] of sortedCharms) { const shortId = createShortId(id); output += `📦 ${info.name} [${shortId}]\n`; - + if (info.readingFrom.length > 0) { output += " ← reads from:\n"; for (const sourceId of info.readingFrom) { - const sourceName = connections.get(sourceId)?.name || createShortId(sourceId); + const sourceName = connections.get(sourceId)?.name || + createShortId(sourceId); output += ` • ${sourceName}\n`; } } - + if (info.readBy.length > 0) { output += " → read by:\n"; for (const targetId of info.readBy) { - const targetName = connections.get(targetId)?.name || createShortId(targetId); + const targetName = connections.get(targetId)?.name || + createShortId(targetId); output += ` • ${targetName}\n`; } } - + if (info.readingFrom.length === 0 && info.readBy.length === 0) { output += " (no connections)\n"; } - + output += "\n"; } @@ -504,11 +517,16 @@ export enum MapFormat { DOT = "dot", } -export async function getCharmConnections(config: SpaceConfig): Promise { +export async function getCharmConnections( + config: SpaceConfig, +): Promise { return await buildConnectionMap(config); } -export function formatSpaceMap(connections: CharmConnectionMap, format: MapFormat): string { +export function formatSpaceMap( + connections: CharmConnectionMap, + format: MapFormat, +): string { switch (format) { case MapFormat.ASCII: return generateAsciiMap(connections); @@ -519,7 +537,10 @@ export function formatSpaceMap(connections: CharmConnectionMap, format: MapForma } } -export async function generateSpaceMap(config: SpaceConfig, format: MapFormat = MapFormat.ASCII): Promise { +export async function generateSpaceMap( + config: SpaceConfig, + format: MapFormat = MapFormat.ASCII, +): Promise { const connections = await getCharmConnections(config); return formatSpaceMap(connections, format); } @@ -578,3 +599,39 @@ export async function inspectCharm( readBy, }; } + +export async function getCharmView( + config: CharmConfig, +): Promise { + const data = await inspectCharm(config); + return data.result?.[UI]; +} + +export function formatViewTree(view: unknown): string { + const isVNode = (v: any): v is { name: string; children: any[] } => { + return v && typeof v === "object" && v.type === "vnode" && v.name; + }; + + const format = ( + node: unknown, + prefix: string, + last: boolean, + ): string => { + const branch = last ? "└─ " : "├─ "; + if (!isVNode(node)) { + return `${prefix}${branch}${String(node)}`; + } + + const children = Array.isArray(node.children) ? node.children : []; + let output = `${prefix}${branch}${node.name}`; + const nextPrefix = prefix + (last ? " " : "│ "); + for (let i = 0; i < children.length; i++) { + const child = children[i]; + const isLast = i === children.length - 1; + output += "\n" + format(child, nextPrefix, isLast); + } + return output; + }; + + return format(view, "", true); +}