Files

223 lines
9.5 KiB
JavaScript

/** 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));
}