/** Read public interfaces and command help without starting an installation. */ import ts from "typescript"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { readdirSync, readFileSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { moduleHttpSurface } from "./docs-http.mjs"; export const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); export const docsDirectory = join(root, "apps/web/content/docs"); const execute = promisify(execFile); export function filesUnder(directory, extension) { return readdirSync(directory, { recursive: true, withFileTypes: true }) .filter((entry) => entry.isFile() && entry.name.endsWith(extension)) .map((entry) => join(entry.parentPath, entry.name)) .sort(); } export function sdkSurface() { const configPath = join(root, "packages/sdk/tsconfig.json"); const config = ts.readConfigFile(configPath, ts.sys.readFile); if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n")); const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, dirname(configPath)); const program = ts.createProgram(parsed.fileNames, parsed.options); const checker = program.getTypeChecker(); const source = program.getSourceFile(join(root, "packages/sdk/src/native.ts")); const declaration = source?.statements.find( (node) => ts.isInterfaceDeclaration(node) && node.name.text === "ScopedShip", ); if (!declaration) throw new Error("Cannot find the public ScopedShip interface"); function argumentsFor(parameter) { const type = checker.getTypeOfSymbolAtLocation(parameter, declaration); if (parameter.valueDeclaration?.dotDotDotToken && checker.isTupleType(type)) { // Mapped operations use (...args: [] | [input] | [input?]). Document the // resolved tuple, not a fictitious argument literally named "args". return checker.getTypeArguments(type).map((_, index) => { const label = type.target.labeledElementDeclarations?.[index]?.name?.getText(); const flags = type.target.elementFlags[index]; return ( (flags & ts.ElementFlags.Rest ? "..." : "") + (label ?? `input${index || ""}`) + (flags & ts.ElementFlags.Optional ? "?" : "") ); }); } return [ parameter.name + (parameter.flags & ts.SymbolFlags.Optional || parameter.valueDeclaration?.questionToken ? "?" : ""), ]; } const remoteSource = program.getSourceFile(join(root, "packages/sdk/src/client.ts")); const remote = remoteSource?.statements.find( (node) => ts.isClassDeclaration(node) && node.name?.text === "OpenshipClient", ); if (!remote) throw new Error("Cannot find the public OpenshipClient class"); const groups = new Map(); for (const [surface, location] of [["native", declaration], ["remote", remote]]) { for (const property of checker.getTypeAtLocation(location).getProperties()) { if (["deploy", "deployment", "organizationId", "http", "options"].includes(property.name)) continue; const type = checker.getTypeOfSymbolAtLocation(property, location); // Root workflow functions are documented separately; groups contain named methods. if (type.getCallSignatures().length) continue; const methods = groups.get(property.name) ?? new Map(); for (const method of type.getProperties()) { const signatures = checker.getTypeOfSymbolAtLocation(method, location).getCallSignatures(); if (!signatures.length) continue; const entry = methods.get(method.name) ?? { name: method.name, calls: [], surfaces: [] }; entry.calls = [...new Set([...entry.calls, ...signatures.map(signature => `${method.name}(${signature.parameters.flatMap(argumentsFor).join(", ")})`)])]; entry.surfaces.push(surface); methods.set(method.name, entry); } if (methods.size) groups.set(property.name, methods); } } return [...groups].map(([group, methods]) => ({ group, methods: [...methods.values()] })); } function helpRows(lines) { const result = []; for (const line of lines) { const match = line.match(/^ (\S.*?)\s{2,}(\S.*)$/); if (match) result.push({ syntax: match[1], description: match[2] }); else if (/^ +\S/.test(line) && result.length) result.at(-1).description += " " + line.trim(); else if (/^ \S/.test(line)) result.push({ syntax: line.trim(), description: "" }); } return result; } export async function cliSurface() { const binary = join(root, "packages/openship/dist/node-entry.js"); const catalog = []; async function visit(parts) { const { stdout } = await execute(process.execPath, [binary, ...parts, "--help"], { cwd: root, timeout: 30_000, maxBuffer: 1_000_000, }); // Working-directory defaults are not part of the public command contract. const help = stdout.replaceAll(root, "."); if (!help.startsWith("Usage: openship")) throw new Error(`Unexpected help for openship ${parts.join(" ")}`); const sections = { introduction: [] }; let section = "introduction"; for (const line of help.split("\n")) { if (/^[A-Z][\w ]+:$/.test(line)) { section = line.slice(0, -1); sections[section] = []; } else sections[section].push(line); } const children = helpRows(sections.Commands ?? []).filter( (row) => !row.syntax.startsWith("help "), ); catalog.push({ command: ["openship", ...parts].join(" "), usage: help.split("\n")[0].replace(/^Usage: /, ""), description: sections.introduction.slice(1).join(" ").trim(), arguments: helpRows(sections.Arguments ?? []), options: helpRows(sections.Options ?? []), children, }); for (let i = 0; i < children.length; i += 4) await Promise.all( children.slice(i, i + 4).map((row) => visit([...parts, row.syntax.split(/[ |]/)[0]])), ); } await visit([]); return catalog.sort((a, b) => a.command.localeCompare(b.command)); } /** Static route inventory: importing the API would initialize real providers. */ export function httpSurface() { const routes = []; const literal = (node) => (node && ts.isStringLiteralLike(node) ? node.text : undefined); const visit = (node, fn) => { fn(node); ts.forEachChild(node, (child) => visit(child, fn)); }; const appFile = join(root, "apps/api/src/app.ts"); const app = ts.createSourceFile( appFile, readFileSync(appFile, "utf8"), ts.ScriptTarget.Latest, true, ); // Browser callback and OAuth discovery routes are mounted at the origin root. // Resolve the two explicit MCP resource aliases without importing the app. const resourceFile = join(root, "apps/api/src/lib/mcp-resource.ts"); const resources = ts.createSourceFile( resourceFile, readFileSync(resourceFile, "utf8"), ts.ScriptTarget.Latest, true, ); const constants = new Map(); visit(resources, (node) => { if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) constants.set(node.name.text, node.initializer); }); const constant = (node) => { if (ts.isStringLiteralLike(node)) return node.text; if (ts.isArrayLiteralExpression(node)) return node.elements.map(constant); if (ts.isIdentifier(node) && constants.has(node.text)) return constant(constants.get(node.text)); throw new Error(`Unresolved discovery path in ${relative(root, resourceFile)}`); }; visit(app, (node) => { if ( !ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression) || node.expression.expression.getText(app) !== "app" ) return; const method = node.expression.name.text.toUpperCase(); if (!["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"].includes(method)) return; const path = node.arguments[0]; let paths; if (literal(path)) paths = [literal(path)]; else if ( ts.isTemplateExpression(path) && path.templateSpans.length === 1 && path.templateSpans[0].expression.getText(app) === "path" ) { let parent = node.parent; while (parent && !ts.isForOfStatement(parent)) parent = parent.parent; if (!parent || parent.expression.getText(app) !== "MCP_RESOURCE_PATHS") throw new Error("Unresolved root route loop"); paths = constant(constants.get("MCP_RESOURCE_PATHS")).map( (value) => path.head.text + value + path.templateSpans[0].literal.text, ); } else throw new Error(`Unresolved root route: ${path.getText(app)}`); for (const path of paths) routes.push({ method, path, module: "auth", access: path.startsWith("/.well-known/") ? "Public" : "Browser callback", localOnly: false, source: relative(root, appFile), }); }); const mounts = new Map(); visit(app, (node) => { if (ts.isCallExpression(node) && node.expression.getText(app) === "app.route") { const path = literal(node.arguments[0]); if (path && ts.isIdentifier(node.arguments[1])) mounts.set(node.arguments[1].text, path); } }); for (const file of filesUnder(join(root, "apps/api/src"), ".ts").filter( (file) => !file.endsWith(".test.ts"), )) { const text = readFileSync(file, "utf8"); if (!text.includes("secureRouter") && !text.includes("new Hono")) continue; routes.push(...moduleHttpSurface(relative(root, file), text, mounts)); } return routes.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method)); }