import { existsSync, watch } from "node:fs"; import { dirname, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import readline from "node:readline"; import type { CommandExecutionOptions } from "../../cli/run-cli.js"; import { CliError } from "../../cli/errors.js"; import { colors } from "../../utils/colors.js"; type ContextFactory = () => Promise | unknown; interface OpsRegistryLike { allOps(): OperationDefinitionLike[]; getOp(name: string): OperationDefinitionLike | undefined; } interface OperationDefinitionLike { name: string; stream: boolean; reactive: boolean; internal: boolean; stages: readonly string[]; cache?: { tags?: Array; ttl?: number | string; swr?: number | string; visibility?: string; }; __meta?: { cacheTags?: string[]; }; execute(args: ExecuteArgs): Promise; } interface ExecuteArgs { ctx: unknown; input: unknown; meta: Record; signal: AbortSignal; events: { publish(event: string, payload: unknown): void; emit(event: string, payload: unknown): void; }; } interface OperationExecutionLike { result: unknown; revalidate?: Array; graph?: Array<{ source: { kind: string; id: string }; targets: Array<{ kind: string; id: string }> }>; cacheKey?: unknown; } interface OperationSummary { name: string; stream: boolean; reactive: boolean; internal: boolean; stages: readonly string[]; cacheTags: string[]; cacheConfig?: { ttl?: number | string; swr?: number | string; visibility?: string; }; } interface LoadedPlayground { registry: OpsRegistryLike; contextFactory: ContextFactory; } export async function runInspectSession(options: CommandExecutionOptions, args: string[]): Promise { const cwd = resolve(process.cwd(), options.cwd ?? "."); const entryPath = resolveEntryPath(cwd, args[0]); const contextOverridePath = args[1] ? resolve(cwd, args[1]!) : undefined; console.log(colors.cyan("▶ arconym inspect")); console.log(colors.dim(`Entry: ${entryPath}`)); if (contextOverridePath) { console.log(colors.dim(`Context: ${contextOverridePath}`)); } let registry: OpsRegistryLike | null = null; let contextFactory: ContextFactory = () => ({}); let summaries: OperationSummary[] = []; let interfaceHandle: readline.Interface | null = null; const watchers = createWatchers([entryPath, contextOverridePath].filter(Boolean) as string[], () => { void scheduleReload("file change"); }); let pendingReason: string | null = null; let reloadTimer: NodeJS.Timeout | null = null; let reloadRunning = false; await performReload("initial load"); interfaceHandle = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: colors.cyan("inspect> "), }); interfaceHandle.on("line", async (line) => { const trimmed = line.trim(); if (!trimmed) { interfaceHandle!.prompt(); return; } const [command, ...restParts] = trimmed.split(/\s+/); const restText = trimmed.slice(command.length).trim(); try { switch (command) { case "help": printHelp(); break; case "list": renderOperations(summaries); break; case "show": showOperation(restParts[0]); break; case "tags": showTags(restParts[0]); break; case "context": showContext(restParts[0]); break; case "call": await callOperation(restParts[0], restText.slice((restParts[0] ?? "").length).trim()); break; case "reload": await performReload("manual reload"); break; case "exit": case "quit": await shutdown(); return; default: console.log(colors.yellow(`Unknown command: ${command}`)); printHelp(); } } catch (error) { console.log(colors.red(formatError(error))); } finally { interfaceHandle!.prompt(); } }); interfaceHandle.on("SIGINT", async () => { console.log(); await shutdown(); }); interfaceHandle.prompt(); await new Promise((resolveClose) => { interfaceHandle!.on("close", () => { cleanupWatchers(watchers); resolveClose(); }); }); async function scheduleReload(reason: string) { pendingReason = reason; if (reloadTimer) clearTimeout(reloadTimer); reloadTimer = setTimeout(() => { void performReload(pendingReason ?? reason); }, 150); } async function performReload(reason: string) { if (reloadRunning) { pendingReason = reason; return; } reloadRunning = true; try { const loaded = await loadPlayground(entryPath, contextOverridePath); registry = loaded.registry; contextFactory = loaded.contextFactory; summaries = registry.allOps().map(summarizeOperation); console.log(colors.green(`Reloaded (${reason}) – ${summaries.length} op${summaries.length === 1 ? "" : "s"}.`)); renderOperations(summaries); pendingReason = null; } catch (error) { console.log(colors.red(`Reload failed: ${formatError(error)}`)); } finally { reloadRunning = false; if (pendingReason) { const followup = pendingReason; pendingReason = null; void performReload(followup); } } } async function shutdown() { interfaceHandle?.close(); } function showOperation(name: string | undefined) { if (!name) { console.log(colors.yellow("Usage: show ")); return; } const summary = summaries.find((entry) => entry.name === name); if (!summary) { console.log(colors.red(`Unknown operation: ${name}`)); return; } printOperationDetails(summary); } function showTags(name: string | undefined) { if (!name) { console.log(colors.yellow("Usage: tags ")); return; } const summary = summaries.find((entry) => entry.name === name); if (!summary) { console.log(colors.red(`Unknown operation: ${name}`)); return; } if (!summary.cacheTags.length) { console.log(colors.dim(`${name} has no cache tags.`)); return; } console.log(colors.cyan(`${name} cache tags:`)); for (const tag of summary.cacheTags) { console.log(` - ${tag}`); } } function showContext(name: string | undefined) { if (!name) { console.log(colors.yellow("Usage: context ")); return; } const summary = summaries.find((entry) => entry.name === name); if (!summary) { console.log(colors.red(`Unknown operation: ${name}`)); return; } if (!summary.stages.length) { console.log(colors.dim(`${name} does not require additional context stages.`)); return; } console.log(colors.cyan(`${name} context stages:`)); for (const stage of summary.stages) { console.log(` - ${stage}`); } } async function callOperation(name: string | undefined, jsonInput: string) { if (!registry) { console.log(colors.red("Registry not loaded.")); return; } if (!name) { console.log(colors.yellow("Usage: call [json]")); return; } const op = registry.getOp(name); if (!op) { console.log(colors.red(`Unknown operation: ${name}`)); return; } let input: unknown = {}; const trimmed = jsonInput.trim(); if (trimmed) { try { input = JSON.parse(trimmed); } catch (error) { console.log(colors.red(`Failed to parse input JSON: ${(error as Error).message}`)); return; } } const execution = await executeOperation(op, contextFactory, input); printExecution(execution); } } function summarizeOperation(op: OperationDefinitionLike): OperationSummary { const cacheTags = new Set(); if (op.cache?.tags) { for (const tag of op.cache.tags) { cacheTags.add(String(tag)); } } if (op.__meta?.cacheTags) { for (const tag of op.__meta.cacheTags) { cacheTags.add(tag); } } const cacheConfig = op.cache && (op.cache.ttl || op.cache.swr || op.cache.visibility) ? { ttl: op.cache.ttl, swr: op.cache.swr, visibility: op.cache.visibility } : undefined; return { name: op.name, stream: op.stream, reactive: op.reactive, internal: op.internal, stages: op.stages, cacheTags: Array.from(cacheTags.values()).sort(), cacheConfig, }; } async function executeOperation( op: OperationDefinitionLike, factory: ContextFactory, input: unknown ): Promise { const ctx = await Promise.resolve(factory()); const controller = new AbortController(); const meta: Record = { name: op.name, kind: "op", tags: [], transport: "cli", mode: "playground", }; const events = { publish: () => void 0, emit: () => void 0, }; return op.execute({ ctx, input, meta, signal: controller.signal, events }); } function renderOperations(operations: OperationSummary[]): void { if (!operations.length) { console.log(colors.yellow("No operations registered.")); return; } for (const op of operations) { const flags = [op.stream ? "stream" : null, op.reactive ? "reactive" : null, op.internal ? "internal" : null] .filter(Boolean) .join(", "); const stageText = op.stages.length ? op.stages.join(", ") : "none"; const tagText = op.cacheTags.length ? op.cacheTags.join(", ") : "none"; console.log(` - ${colors.cyan(op.name)}${flags ? colors.dim(` [${flags}]`) : ""}`); const details: string[] = [`stages: ${stageText}`, `tags: ${tagText}`]; if (op.cacheConfig?.ttl) details.push(`ttl: ${String(op.cacheConfig.ttl)}`); if (op.cacheConfig?.swr) details.push(`swr: ${String(op.cacheConfig.swr)}`); if (op.cacheConfig?.visibility) details.push(`visibility: ${op.cacheConfig.visibility}`); console.log(colors.dim(` ${details.join(" | ")}`)); } } function printOperationDetails(summary: OperationSummary): void { console.log(colors.cyan(summary.name)); console.log(colors.dim(` stream: ${summary.stream}`)); console.log(colors.dim(` reactive: ${summary.reactive}`)); console.log(colors.dim(` internal: ${summary.internal}`)); console.log(colors.dim(` stages: ${summary.stages.join(", ") || "none"}`)); console.log(colors.dim(` cache tags: ${summary.cacheTags.join(", ") || "none"}`)); if (summary.cacheConfig) { console.log(colors.dim(` cache ttl: ${summary.cacheConfig.ttl ?? "none"}`)); console.log(colors.dim(` cache swr: ${summary.cacheConfig.swr ?? "none"}`)); console.log(colors.dim(` cache visibility: ${summary.cacheConfig.visibility ?? "default"}`)); } } function printExecution(execution: OperationExecutionLike): void { if (isAsyncIterable(execution.result)) { console.log(colors.yellow("Result is an async iterable. Streaming display is not available.")); } else if (execution.result instanceof Response) { console.log(colors.yellow(`Result is a Response (status ${execution.result.status}).`)); } else { console.log(colors.green("Result:")); console.log(JSON.stringify(execution.result, null, 2)); } if (execution.revalidate?.length) { console.log(colors.cyan("Revalidate tags:")); for (const tag of execution.revalidate) { console.log(` - ${tag}`); } } if (execution.graph?.length) { console.log(colors.cyan(`Graph links: ${execution.graph.length}`)); } if (execution.cacheKey !== undefined) { console.log(colors.dim(`cache key: ${JSON.stringify(execution.cacheKey)}`)); } } function printHelp(): void { console.log(colors.cyan("Commands:")); console.log(" list – list registered operations"); console.log(" show – show operation details"); console.log(" context – list required context stages"); console.log(" tags – list cache tags"); console.log(" call [json] – execute an operation with optional JSON input"); console.log(" reload – force reload operations"); console.log(" help – show this list"); console.log(" exit | quit – leave playground"); } function isAsyncIterable(value: unknown): value is AsyncIterable { return Boolean(value && typeof (value as any)[Symbol.asyncIterator] === "function"); } function resolveEntryPath(cwd: string, provided?: string): string { if (provided) { const explicit = resolve(cwd, provided); if (!existsSync(explicit)) { throw new CliError(`Entry file not found: ${explicit}`); } return explicit; } const candidates = [ "src/ops/root.ts", "src/ops/_root.ts", "src/ops/index.ts", "src/ops/root.js", "src/ops/_root.js", "src/ops/index.js", ]; for (const candidate of candidates) { const absolute = resolve(cwd, candidate); if (existsSync(absolute)) return absolute; } throw new CliError("Unable to locate an ops entry file. Provide a path: arconym inspect "); } async function loadPlayground(entryPath: string, contextOverridePath: string | undefined): Promise { const module = await import(withCacheBuster(entryPath)); const registry = resolveRegistryExport(module); let factory = extractContextFactory(module); if (contextOverridePath) { factory = await loadContextFactory(contextOverridePath); } if (!factory) { factory = () => ({}); } return { registry, contextFactory: factory }; } async function loadContextFactory(path: string): Promise { const mod = await import(withCacheBuster(path)); const factory = extractContextFactory(mod); if (!factory) { throw new CliError(`Context module does not export a factory function: ${path}`); } return factory; } function resolveRegistryExport(module: Record): OpsRegistryLike { const defaultExport = module["default"] as Record | undefined; const candidates = [module["registry"], defaultExport?.["registry"], defaultExport]; for (const candidate of candidates) { if (isOpsRegistry(candidate)) return candidate as OpsRegistryLike; } throw new CliError("Entry module does not export an Ops registry."); } function extractContextFactory(module: Record): ContextFactory | undefined { const defaultExport = module["default"] as Record | undefined; const playgroundExport = module["playground"] as Record | undefined; const defaultPlayground = defaultExport?.["playground"] as Record | undefined; const candidates: unknown[] = [ module["createPlaygroundContext"], module["createContext"], module["contextFactory"], playgroundExport?.["createContext"], defaultExport, defaultExport?.["createPlaygroundContext"], defaultExport?.["createContext"], defaultExport?.["contextFactory"], defaultPlayground?.["createContext"], ]; for (const candidate of candidates) { if (typeof candidate === "function") return candidate as ContextFactory; } return undefined; } function withCacheBuster(path: string): string { return `${pathToFileURL(path).href}?t=${Date.now()}`; } function isOpsRegistry(value: unknown): value is OpsRegistryLike { if (!value || typeof value !== "object") return false; const record = value as Record; return ( typeof record.registerOp === "function" && typeof record.registerHttp === "function" && typeof record.allOps === "function" ); } function createWatchers(paths: string[], onChange: () => void) { const watchers: Array> = []; const directories = Array.from(new Set(paths.map((file) => dirname(file)))); for (const dir of directories) { if (!existsSync(dir)) continue; try { const watcher = watch(dir, { recursive: true }, () => onChange()); watchers.push(watcher); } catch { // ignore watcher failures } } return watchers; } function cleanupWatchers(watchers: Array>) { for (const watcher of watchers) { try { watcher.close(); } catch { // ignore close errors } } } function formatError(error: unknown): string { if (error instanceof Error) return error.message; return String(error); }