diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9156698..6f9c812 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,11 @@ jobs: - name: MCP tests run: bun run mcp:test + - name: CLI tests + run: bun run cli:test + + - name: CLI build + run: bun run --cwd apps/cli build + - name: Build run: bun run build diff --git a/apps/cli/README.md b/apps/cli/README.md new file mode 100644 index 0000000..0576fa9 --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,22 @@ +# OOXML CLI + +Use the OOXML reference from a terminal or an agent skill. Sign in with Clerk to query the hosted ooxml.dev service. + +While the package is private, run it from the repository root: + +```bash +bun run ooxml login +bun run ooxml search "paragraph spacing" +bun run ooxml element w:p +bun run ooxml children w:p +bun run ooxml attributes w:p +bun run ooxml logout +``` + +During this private test, the CLI stores sign-in tokens as plain text in your application data directory. Do not use it from a shared account. The CLI does not store queries or results. + +The bundled [`research-ooxml`](../../skills/research-ooxml/SKILL.md) skill tells agents which commands to use and how to combine schema and specification evidence. + +MCP is an internal transport detail. It is not part of the CLI or skill interface. + +The package remains private while we test it. Secure credential storage and npm publishing are separate release steps. diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000..22bfcb8 --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,32 @@ +{ + "name": "@ooxml-dev/cli", + "version": "0.1.0", + "description": "Query the ooxml.dev reference from a terminal or agent skill.", + "private": true, + "type": "module", + "bin": { + "ooxml": "./dist/cli.mjs" + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "build": "vp pack --clean", + "dev": "bun src/cli.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/client": "2.0.0", + "open": "11.0.0", + "proper-lockfile": "4.1.2" + }, + "devDependencies": { + "@types/node": "^20.19.0", + "@types/proper-lockfile": "^4.1.4", + "typescript": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/apps/cli/src/arguments.ts b/apps/cli/src/arguments.ts new file mode 100644 index 0000000..f3b88a3 --- /dev/null +++ b/apps/cli/src/arguments.ts @@ -0,0 +1,169 @@ +export type OoxmlCommand = + | { name: "help" } + | { name: "version" } + | { name: "login" } + | { name: "logout" } + | { name: "query"; tool: string; input: Record }; + +interface ParsedOptions { + positionals: string[]; + values: Map; +} + +function parseOptions(args: string[], allowed: string[]): ParsedOptions { + const positionals: string[] = []; + const values = new Map(); + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (!argument.startsWith("--")) { + positionals.push(argument); + continue; + } + if (!allowed.includes(argument)) throw new Error(`Unknown option: ${argument}`); + const value = args[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`${argument} needs a value`); + values.set(argument, value); + index += 1; + } + return { positionals, values }; +} + +function singleValue(args: string[], usage: string, options: string[] = []): ParsedOptions { + const parsed = parseOptions(args, options); + if (parsed.positionals.length !== 1) throw new Error(`Usage: ${usage}`); + return parsed; +} + +function optionalNumber( + value: string | undefined, + option: string, + minimum: number, + maximum: number, +) { + if (value === undefined) return undefined; + const number = Number(value); + if (!Number.isInteger(number) || number < minimum || number > maximum) { + throw new Error(`${option} must be an integer between ${minimum} and ${maximum}`); + } + return number; +} + +function withProfile(qname: string, profile: string | undefined): Record { + return profile ? { qname, profile } : { qname }; +} + +export function parseArguments(args: string[]): OoxmlCommand { + const [command, ...rest] = args; + if (!command || command === "help" || command === "--help" || command === "-h") { + return { name: "help" }; + } + if (command === "--version" || command === "-v" || command === "version") { + if (rest.length) throw new Error("The version command does not accept arguments"); + return { name: "version" }; + } + if (command === "login" || command === "logout") { + if (rest.length) throw new Error(`The ${command} command does not accept arguments`); + return { name: command }; + } + + if (command === "search") { + const parsed = singleValue(rest, "ooxml search [--part <1-4>] [--limit <1-20>]", [ + "--part", + "--limit", + ]); + const part = optionalNumber(parsed.values.get("--part"), "--part", 1, 4); + const limit = optionalNumber(parsed.values.get("--limit"), "--limit", 1, 20); + return { + name: "query", + tool: "ooxml_search", + input: { query: parsed.positionals[0], ...(part && { part }), ...(limit && { limit }) }, + }; + } + + if (command === "section") { + const parsed = singleValue(rest, "ooxml section [--part <1-4>]", ["--part"]); + const part = optionalNumber(parsed.values.get("--part"), "--part", 1, 4); + return { + name: "query", + tool: "ooxml_section", + input: { section_id: parsed.positionals[0], ...(part && { part }) }, + }; + } + + if (command === "parts") { + const parsed = parseOptions(rest, ["--part"]); + if (parsed.positionals.length) throw new Error("Usage: ooxml parts [--part <1-4>]"); + const part = optionalNumber(parsed.values.get("--part"), "--part", 1, 4); + return { name: "query", tool: "ooxml_parts", input: part ? { part } : {} }; + } + + const qnameTools: Record = { + element: "ooxml_element", + type: "ooxml_type", + children: "ooxml_children", + attributes: "ooxml_attributes", + enum: "ooxml_enum", + }; + if (Object.hasOwn(qnameTools, command)) { + const parsed = singleValue(rest, `ooxml ${command} [--profile ]`, [ + "--profile", + ]); + return { + name: "query", + tool: qnameTools[command], + input: withProfile(parsed.positionals[0], parsed.values.get("--profile")), + }; + } + + if (command === "namespace") { + const parsed = parseOptions(rest, ["--uri"]); + if ( + parsed.positionals.length > 1 || + (parsed.positionals.length && parsed.values.has("--uri")) + ) { + throw new Error("Usage: ooxml namespace [query] [--uri ]"); + } + return { + name: "query", + tool: "ooxml_namespace", + input: parsed.values.has("--uri") + ? { uri: parsed.values.get("--uri") } + : parsed.positionals.length + ? { query: parsed.positionals[0] } + : {}, + }; + } + + if (command === "package-part") { + const parsed = parseOptions(rest, ["--content-type", "--relationship-type"]); + const modes = [ + parsed.positionals.length ? "query" : undefined, + parsed.values.has("--content-type") ? "content_type" : undefined, + parsed.values.has("--relationship-type") ? "relationship_type" : undefined, + ].filter(Boolean); + if (parsed.positionals.length > 1 || modes.length > 1) { + throw new Error( + "Usage: ooxml package-part [query] [--content-type | --relationship-type ]", + ); + } + const input = parsed.positionals.length + ? { query: parsed.positionals[0] } + : parsed.values.has("--content-type") + ? { content_type: parsed.values.get("--content-type") } + : parsed.values.has("--relationship-type") + ? { relationship_type: parsed.values.get("--relationship-type") } + : {}; + return { name: "query", tool: "ooxml_package_part", input }; + } + + if (command === "preset-shape") { + const parsed = singleValue(rest, "ooxml preset-shape "); + return { + name: "query", + tool: "ooxml_preset_shape", + input: { shape: parsed.positionals[0] }, + }; + } + + throw new Error(`Unknown command: ${command}`); +} diff --git a/apps/cli/src/browser-auth.ts b/apps/cli/src/browser-auth.ts new file mode 100644 index 0000000..89fba4b --- /dev/null +++ b/apps/cli/src/browser-auth.ts @@ -0,0 +1,143 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import open from "open"; +import type { CliOAuthProvider } from "./oauth-provider.js"; + +const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; + +export function isSafeAuthorizationUrl(url: URL): boolean { + return ( + url.protocol === "https:" || + (url.protocol === "http:" && ["127.0.0.1", "::1", "localhost"].includes(url.hostname)) + ); +} + +export function signInMessage(url: URL): string { + return `Opening your browser to sign in…\nIf it does not open, visit:\n${url}`; +} + +function callbackPage(success: boolean): string { + const title = success ? "Signed in to ooxml.dev" : "Sign-in failed"; + const detail = success + ? "You can close this window and return to the terminal." + : "Return to the terminal and try again."; + const closeWindow = success ? "setTimeout(()=>window.close(),2000);" : ""; + return `${title}

${title}

${detail}

`; +} + +interface OAuthCallback { + port: number; + result: Promise; +} + +export async function startOAuthCallback( + port: number, + validatesState: (state: string | null) => boolean, + timeoutMs = CALLBACK_TIMEOUT_MS, +): Promise { + let settled = false; + let timeout: ReturnType | undefined; + let resolveResult!: (params: URLSearchParams) => void; + let rejectResult!: (error: Error) => void; + const result = new Promise((resolve, reject) => { + resolveResult = resolve; + rejectResult = reject; + }); + result.catch(() => {}); + + const server = createServer((request, response) => { + if (settled) { + response.writeHead(409).end("Sign-in callback already handled"); + return; + } + const address = server.address() as AddressInfo; + const expectedHost = `127.0.0.1:${address.port}`; + if (request.headers.host !== expectedHost) { + response.writeHead(400).end("Invalid sign-in callback"); + return; + } + + const url = new URL(request.url ?? "/", `http://${expectedHost}`); + if (url.pathname !== "/callback") { + response.writeHead(404).end(); + return; + } + if (!validatesState(url.searchParams.get("state"))) { + response.writeHead(400, { "Cache-Control": "no-store" }).end("Invalid sign-in state"); + return; + } + if (!url.searchParams.has("code") && !url.searchParams.has("error")) { + response.writeHead(400, { "Cache-Control": "no-store" }).end("Invalid sign-in callback"); + return; + } + + settled = true; + if (timeout) clearTimeout(timeout); + const success = Boolean(url.searchParams.get("code")) && !url.searchParams.get("error"); + response.writeHead(success ? 200 : 400, { + "Cache-Control": "no-store", + "Content-Type": "text/html; charset=utf-8", + "Referrer-Policy": "no-referrer", + }); + response.end(callbackPage(success)); + server.close(); + resolveResult(url.searchParams); + }); + + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { + server.removeAllListeners("error"); + resolve(); + }); + }); + } catch (error) { + settled = true; + const callbackError = new Error(`Could not start the local sign-in callback on port ${port}`, { + cause: error, + }); + rejectResult(callbackError); + throw callbackError; + } + + server.once("error", (error) => { + if (settled) return; + settled = true; + if (timeout) clearTimeout(timeout); + rejectResult(new Error("The local sign-in callback stopped", { cause: error })); + }); + timeout = setTimeout(() => { + if (settled) return; + settled = true; + server.close(); + rejectResult(new Error("Timed out waiting for browser sign-in")); + }, timeoutMs); + + return { port: (server.address() as AddressInfo).port, result }; +} + +export async function authorizeInBrowser( + provider: CliOAuthProvider, + port: number, + finishAuth: (params: URLSearchParams) => Promise, +): Promise { + const authorizationUrl = provider.pendingAuthorizationUrl; + if (!authorizationUrl) throw new Error("The OOXML service did not provide a sign-in URL"); + if (!isSafeAuthorizationUrl(authorizationUrl)) { + throw new Error("The OOXML service returned an unsafe sign-in URL"); + } + + const callback = await startOAuthCallback(port, (state) => provider.validatesState(state)); + console.error(signInMessage(authorizationUrl)); + try { + await open(authorizationUrl.toString()); + } catch {} + + const params = await callback.result; + if (params.get("error")) throw new Error("Sign-in was canceled or denied"); + if (!provider.validatesState(params.get("state"))) { + throw new Error("Sign-in could not be verified. Try again."); + } + await finishAuth(params); +} diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts new file mode 100644 index 0000000..a7304e3 --- /dev/null +++ b/apps/cli/src/cli.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env node + +import type { ContentBlock } from "@modelcontextprotocol/client"; +import { parseArguments } from "./arguments.js"; +import { callbackPort, CLI_VERSION } from "./constants.js"; +import { CredentialStore } from "./credentials.js"; +import { connectToMcp } from "./mcp-client.js"; + +const HELP = `Search and inspect the OOXML reference. + +Usage: + ooxml login + ooxml search [--part <1-4>] [--limit <1-20>] + ooxml section [--part <1-4>] + ooxml parts [--part <1-4>] + ooxml element [--profile ] + ooxml type [--profile ] + ooxml children [--profile ] + ooxml attributes [--profile ] + ooxml enum [--profile ] + ooxml namespace [query] [--uri ] + ooxml package-part [query] [--content-type | --relationship-type ] + ooxml preset-shape + ooxml logout + +Example: + ooxml element w:p`; + +async function withClient( + allowBrowser: boolean, + callback: (client: Awaited>["client"]) => Promise, +): Promise { + const connection = await connectToMcp({ + allowBrowser, + callbackPort: callbackPort(), + }); + try { + return await callback(connection.client); + } finally { + await connection.close(); + } +} + +function printContent(content: ContentBlock[]): void { + for (const item of content) { + if (item.type === "text") console.log(item.text); + else console.log(JSON.stringify(item)); + } +} + +async function main(): Promise { + const command = parseArguments(process.argv.slice(2)); + if (command.name === "help") { + console.log(HELP); + return; + } + if (command.name === "version") { + console.log(CLI_VERSION); + return; + } + if (command.name === "logout") { + const credentials = await new CredentialStore().open(); + try { + await credentials.clear(); + console.log("Signed out on this device."); + } finally { + await credentials.close(); + } + return; + } + if (command.name === "login") { + await withClient(true, async () => {}); + console.log("Signed in to ooxml.dev."); + return; + } + await withClient(false, async (client) => { + const result = await client.callTool({ name: command.tool, arguments: command.input }); + printContent(result.content); + if (result.isError) process.exitCode = 1; + }); +} + +try { + await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts new file mode 100644 index 0000000..aeea5ff --- /dev/null +++ b/apps/cli/src/constants.ts @@ -0,0 +1,15 @@ +export const CLI_NAME = "ooxml"; +export const CLI_VERSION = "0.1.0"; +export const MCP_URL = "https://api.ooxml.dev/mcp"; +export const DEFAULT_CALLBACK_PORT = 53_682; + +export function callbackPort(): number { + const rawPort = process.env.OOXML_CALLBACK_PORT; + if (!rawPort) return DEFAULT_CALLBACK_PORT; + + const port = Number(rawPort); + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error("OOXML_CALLBACK_PORT must be an integer between 1 and 65535"); + } + return port; +} diff --git a/apps/cli/src/credentials.ts b/apps/cli/src/credentials.ts new file mode 100644 index 0000000..7314788 --- /dev/null +++ b/apps/cli/src/credentials.ts @@ -0,0 +1,82 @@ +import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import type { + OAuthDiscoveryState, + StoredOAuthClientInformation, + StoredOAuthTokens, +} from "@modelcontextprotocol/client"; +import { lock as lockFile } from "proper-lockfile"; + +export interface StoredCredentials { + clientInformation?: StoredOAuthClientInformation; + discoveryState?: OAuthDiscoveryState; + tokens?: StoredOAuthTokens; + verifier?: string; +} + +function defaultConfigDirectory(): string { + if (process.platform === "win32") { + return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "ooxml"); + } + if (process.platform === "darwin") { + return join(homedir(), "Library", "Application Support", "ooxml"); + } + return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "ooxml"); +} + +export function credentialsPath(): string { + return join(process.env.OOXML_CONFIG_DIR ?? defaultConfigDirectory(), "credentials.json"); +} + +export class CredentialStore { + constructor(readonly path = credentialsPath()) {} + + async open(): Promise { + const directory = dirname(this.path); + await mkdir(directory, { recursive: true, mode: 0o700 }); + if (process.platform !== "win32") await chmod(directory, 0o700); + try { + const release = await lockFile(this.path, { + realpath: false, + retries: { retries: 20, factor: 1.25, minTimeout: 50, maxTimeout: 500, randomize: true }, + }); + return new CredentialSession(this.path, release); + } catch (error) { + throw new Error("Another OOXML command is running. Wait for it to finish, then try again.", { + cause: error, + }); + } + } +} + +export class CredentialSession { + constructor( + readonly path: string, + private readonly release: () => Promise, + ) {} + + async read(): Promise { + try { + return JSON.parse(await readFile(this.path, "utf8")) as StoredCredentials; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw new Error(`Could not read CLI credentials at ${this.path}`, { cause: error }); + } + } + + async write(credentials: StoredCredentials): Promise { + const temporaryPath = `${this.path}.${process.pid}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 }); + if (process.platform !== "win32") await chmod(temporaryPath, 0o600); + await rename(temporaryPath, this.path); + } + + async clear(): Promise { + await rm(this.path, { force: true }); + } + + close(): Promise { + return this.release(); + } +} diff --git a/apps/cli/src/mcp-client.ts b/apps/cli/src/mcp-client.ts new file mode 100644 index 0000000..07a26ab --- /dev/null +++ b/apps/cli/src/mcp-client.ts @@ -0,0 +1,69 @@ +import { + Client, + StreamableHTTPClientTransport, + UnauthorizedError, +} from "@modelcontextprotocol/client"; +import { authorizeInBrowser } from "./browser-auth.js"; +import { CLI_NAME, CLI_VERSION, MCP_URL } from "./constants.js"; +import { CredentialStore } from "./credentials.js"; +import { CliOAuthProvider } from "./oauth-provider.js"; + +interface ConnectOptions { + allowBrowser: boolean; + callbackPort: number; +} + +export interface ConnectedMcpClient { + client: Client; + close(): Promise; +} + +function newClient(): Client { + return new Client({ name: `${CLI_NAME}-cli`, version: CLI_VERSION }, { capabilities: {} }); +} + +function newTransport(serverUrl: URL, provider: CliOAuthProvider): StreamableHTTPClientTransport { + return new StreamableHTTPClientTransport(serverUrl, { authProvider: provider }); +} + +export async function connectToMcp(options: ConnectOptions): Promise { + const redirectUrl = `http://127.0.0.1:${options.callbackPort}/callback`; + const serverUrl = new URL(MCP_URL); + const credentials = await new CredentialStore().open(); + try { + const provider = new CliOAuthProvider(redirectUrl, credentials); + await provider.load(); + + let client = newClient(); + let transport = newTransport(serverUrl, provider); + try { + await client.connect(transport); + } catch (error) { + if (!(error instanceof UnauthorizedError)) throw error; + if (!options.allowBrowser) { + throw new Error("You are not signed in. Run `ooxml login` first."); + } + + await authorizeInBrowser(provider, options.callbackPort, (params) => + transport.finishAuth(params), + ); + client = newClient(); + transport = newTransport(serverUrl, provider); + await client.connect(transport); + } + + return { + client, + close: async () => { + try { + await client.close(); + } finally { + await credentials.close(); + } + }, + }; + } catch (error) { + await credentials.close(); + throw error; + } +} diff --git a/apps/cli/src/oauth-provider.ts b/apps/cli/src/oauth-provider.ts new file mode 100644 index 0000000..7d3415f --- /dev/null +++ b/apps/cli/src/oauth-provider.ts @@ -0,0 +1,109 @@ +import { randomUUID } from "node:crypto"; +import type { + OAuthClientInformationContext, + OAuthClientMetadata, + OAuthClientProvider, + OAuthDiscoveryState, + StoredOAuthClientInformation, + StoredOAuthTokens, +} from "@modelcontextprotocol/client"; +import { type CredentialSession, type StoredCredentials } from "./credentials.js"; + +export class CliOAuthProvider implements OAuthClientProvider { + pendingAuthorizationUrl?: URL; + private credentials: StoredCredentials = {}; + private expectedState?: string; + + readonly clientMetadata: OAuthClientMetadata; + + constructor( + readonly redirectUrl: string, + private readonly store: CredentialSession, + ) { + this.clientMetadata = { + client_name: "OOXML CLI", + redirect_uris: [redirectUrl], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + application_type: "native", + token_endpoint_auth_method: "none", + }; + } + + async load(): Promise { + this.credentials = await this.store.read(); + } + + state(): string { + this.expectedState ??= randomUUID(); + return this.expectedState; + } + + validatesState(state: string | null): boolean { + return Boolean(this.expectedState && state === this.expectedState); + } + + clientInformation( + _context?: OAuthClientInformationContext, + ): StoredOAuthClientInformation | undefined { + return this.credentials.clientInformation; + } + + async saveClientInformation( + clientInformation: StoredOAuthClientInformation, + _context?: OAuthClientInformationContext, + ): Promise { + this.credentials.clientInformation = clientInformation; + await this.persist(); + } + + tokens(_context?: OAuthClientInformationContext): StoredOAuthTokens | undefined { + return this.credentials.tokens; + } + + async saveTokens( + tokens: StoredOAuthTokens, + _context?: OAuthClientInformationContext, + ): Promise { + this.credentials.tokens = tokens; + delete this.credentials.verifier; + await this.persist(); + } + + redirectToAuthorization(authorizationUrl: URL): void { + this.pendingAuthorizationUrl = authorizationUrl; + } + + async saveCodeVerifier(verifier: string): Promise { + this.credentials.verifier = verifier; + await this.persist(); + } + + codeVerifier(): string { + if (!this.credentials.verifier) throw new Error("Sign-in session data is missing"); + return this.credentials.verifier; + } + + async saveDiscoveryState(discoveryState: OAuthDiscoveryState): Promise { + this.credentials.discoveryState = discoveryState; + await this.persist(); + } + + discoveryState(): OAuthDiscoveryState | undefined { + return this.credentials.discoveryState; + } + + async invalidateCredentials( + scope: "all" | "client" | "tokens" | "verifier" | "discovery", + ): Promise { + if (scope === "all" || scope === "client") delete this.credentials.clientInformation; + if (scope === "all" || scope === "tokens") delete this.credentials.tokens; + if (scope === "all" || scope === "verifier") delete this.credentials.verifier; + if (scope === "all" || scope === "discovery") delete this.credentials.discoveryState; + await this.persist(); + } + + private persist(): Promise { + return this.store.write(this.credentials); + } +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 0000000..72c43f7 --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "target": "ES2023", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/apps/cli/vite.config.ts b/apps/cli/vite.config.ts new file mode 100644 index 0000000..283b848 --- /dev/null +++ b/apps/cli/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + pack: { + entry: ["src/cli.ts"], + format: ["esm"], + platform: "node", + target: "node20", + }, +}); diff --git a/bun.lock b/bun.lock index 24b0077..faef7e4 100644 --- a/bun.lock +++ b/bun.lock @@ -12,9 +12,27 @@ "vite-plus": "catalog:", }, }, + "apps/cli": { + "name": "@ooxml-dev/cli", + "version": "0.1.0", + "bin": { + "ooxml": "./dist/cli.mjs", + }, + "dependencies": { + "@modelcontextprotocol/client": "2.0.0", + "open": "11.0.0", + "proper-lockfile": "4.1.2", + }, + "devDependencies": { + "@types/node": "^20.19.0", + "@types/proper-lockfile": "^4.1.4", + "typescript": "catalog:", + "vite-plus": "catalog:", + }, + }, "apps/mcp-server": { "name": "@ooxml-dev/mcp-server", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { "@clerk/backend": "^3.16.3", "@cloudflare/workers-oauth-provider": "^0.10.3", @@ -313,6 +331,8 @@ "@manypkg/tools": ["@manypkg/tools@2.1.2", "", { "dependencies": { "jju": "^1.4.0", "tinyglobby": "^0.2.13", "yaml": "^2.9.0" } }, "sha512-6QEf6yqFbETdwGITKq57aYoPfX/3K8XFNwsAlx0C1M7o8cb79sv1M3w+tWuWvIcSbNqrLF7OD7YpZMVVz335hQ=="], + "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw=="], + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="], "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="], @@ -321,6 +341,8 @@ "@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="], + "@ooxml-dev/cli": ["@ooxml-dev/cli@workspace:apps/cli"], + "@ooxml-dev/mcp-server": ["@ooxml-dev/mcp-server@workspace:apps/mcp-server"], "@ooxml-dev/shared": ["@ooxml-dev/shared@workspace:packages/shared"], @@ -591,14 +613,18 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], + "@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], "@types/pg": ["@types/pg@8.11.6", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ=="], + "@types/proper-lockfile": ["@types/proper-lockfile@4.1.4", "", { "dependencies": { "@types/retry": "*" } }, "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ=="], + "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/retry": ["@types/retry@0.12.5", "", {}, "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], @@ -745,6 +771,8 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "cac": ["cac@7.0.0", "", {}, "sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ=="], "caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="], @@ -775,6 +803,8 @@ "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -783,6 +813,12 @@ "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], @@ -815,6 +851,10 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], @@ -879,16 +919,28 @@ "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jju": ["jju@1.4.0", "", {}, "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA=="], + "jose": ["jose@6.2.8", "", {}, "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ=="], + "js-cookie": ["js-cookie@3.0.7", "", {}, "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -1053,6 +1105,8 @@ "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], + "oxfmt": ["oxfmt@0.62.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.62.0", "@oxfmt/binding-android-arm64": "0.62.0", "@oxfmt/binding-darwin-arm64": "0.62.0", "@oxfmt/binding-darwin-x64": "0.62.0", "@oxfmt/binding-freebsd-x64": "0.62.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.62.0", "@oxfmt/binding-linux-arm-musleabihf": "0.62.0", "@oxfmt/binding-linux-arm64-gnu": "0.62.0", "@oxfmt/binding-linux-arm64-musl": "0.62.0", "@oxfmt/binding-linux-ppc64-gnu": "0.62.0", "@oxfmt/binding-linux-riscv64-gnu": "0.62.0", "@oxfmt/binding-linux-riscv64-musl": "0.62.0", "@oxfmt/binding-linux-s390x-gnu": "0.62.0", "@oxfmt/binding-linux-x64-gnu": "0.62.0", "@oxfmt/binding-linux-x64-musl": "0.62.0", "@oxfmt/binding-openharmony-arm64": "0.62.0", "@oxfmt/binding-win32-arm64-msvc": "0.62.0", "@oxfmt/binding-win32-ia32-msvc": "0.62.0", "@oxfmt/binding-win32-x64-msvc": "0.62.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ=="], "oxlint": ["oxlint@1.77.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.77.0", "@oxlint/binding-android-arm64": "1.77.0", "@oxlint/binding-darwin-arm64": "1.77.0", "@oxlint/binding-darwin-x64": "1.77.0", "@oxlint/binding-freebsd-x64": "1.77.0", "@oxlint/binding-linux-arm-gnueabihf": "1.77.0", "@oxlint/binding-linux-arm-musleabihf": "1.77.0", "@oxlint/binding-linux-arm64-gnu": "1.77.0", "@oxlint/binding-linux-arm64-musl": "1.77.0", "@oxlint/binding-linux-ppc64-gnu": "1.77.0", "@oxlint/binding-linux-riscv64-gnu": "1.77.0", "@oxlint/binding-linux-riscv64-musl": "1.77.0", "@oxlint/binding-linux-s390x-gnu": "1.77.0", "@oxlint/binding-linux-x64-gnu": "1.77.0", "@oxlint/binding-linux-x64-musl": "1.77.0", "@oxlint/binding-openharmony-arm64": "1.77.0", "@oxlint/binding-win32-arm64-msvc": "1.77.0", "@oxlint/binding-win32-ia32-msvc": "1.77.0", "@oxlint/binding-win32-x64-msvc": "1.77.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg=="], @@ -1067,6 +1121,8 @@ "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -1083,6 +1139,8 @@ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], @@ -1101,10 +1159,14 @@ "postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="], + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], @@ -1145,6 +1207,10 @@ "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -1159,12 +1225,18 @@ "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="], "shiki": ["shiki@3.21.0", "", { "dependencies": { "@shikijs/core": "3.21.0", "@shikijs/engine-javascript": "3.21.0", "@shikijs/engine-oniguruma": "3.21.0", "@shikijs/langs": "3.21.0", "@shikijs/themes": "3.21.0", "@shikijs/types": "3.21.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w=="], "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], @@ -1219,7 +1291,7 @@ "undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="], - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], @@ -1253,6 +1325,8 @@ "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], "workerd": ["workerd@1.20260124.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260124.0", "@cloudflare/workerd-darwin-arm64": "1.20260124.0", "@cloudflare/workerd-linux-64": "1.20260124.0", "@cloudflare/workerd-linux-arm64": "1.20260124.0", "@cloudflare/workerd-windows-64": "1.20260124.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-JN6voV/fUQK342a39Rl+20YVmtIXZVbpxc7V/m809lUnlTGPy4aa5MI7PMoc+9qExgAEOw9cojvN5zOfqmMWLg=="], @@ -1261,6 +1335,8 @@ "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], @@ -1289,6 +1365,8 @@ "@neondatabase/serverless/@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], + "@ooxml-dev/web/@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -1323,7 +1401,7 @@ "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "@neondatabase/serverless/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@ooxml-dev/web/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], @@ -1346,7 +1424,5 @@ "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], - - "@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], } } diff --git a/package.json b/package.json index 26f6911..5c55cdb 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ "mcp:users": "bun --env-file=.env apps/mcp-server/scripts/users.ts", "mcp:preset-shapes:generate": "bun scripts/generate-preset-shape-guides.ts", "mcp:test": "bun test tests/scripts/source-artifact.test.ts tests/mcp-server/tools-list.test.ts tests/mcp-server/opc-parts.test.ts tests/mcp-server/mcp-auth.test.ts tests/mcp-server/oauth-authorization.test.ts tests/mcp-server/preset-shape-guides.test.ts", + "ooxml": "bun apps/cli/src/cli.ts", + "cli:test": "bun test tests/cli/", "sources:sync": "bun scripts/sources-sync.ts", "pdf:ingest": "bun scripts/ingest-pdf/pipeline.ts", "pdf:chunk": "bun scripts/ingest-pdf/chunk.ts", diff --git a/skills/research-ooxml/SKILL.md b/skills/research-ooxml/SKILL.md new file mode 100644 index 0000000..7787dab --- /dev/null +++ b/skills/research-ooxml/SKILL.md @@ -0,0 +1,35 @@ +--- +name: research-ooxml +description: Research published OOXML specification text, schema structure, namespaces, package parts, and preset shapes through the ooxml CLI. Use when an agent needs evidence about valid OOXML markup, element children or attributes, simple-type values, specification sections, or OPC package metadata while implementing or reviewing document tooling. +--- + +# Research OOXML + +Use the `ooxml` CLI to gather evidence. Do not edit files unless the user's request also asks for changes. + +## Start + +Run `ooxml --help` to confirm the CLI is installed. If a command says the user is not signed in, run +`ooxml login` and wait for the user to finish signing in through the browser. + +## Choose evidence + +- Search prose with `ooxml search ""`. Use `--part` only when the relevant ECMA part is known. +- Read a known section with `ooxml section [--part <1-4>]`. +- Inspect an element with `ooxml element `. +- Find legal children with `ooxml children `. +- Find attributes, including inherited ones, with `ooxml attributes `. +- Inspect a named type with `ooxml type `. +- List a simple type's allowed values with `ooxml enum `. +- Discover namespaces with `ooxml namespace [query]`; use `--uri` for an exact URI. +- Inspect OPC part metadata with `ooxml package-part [query]`. +- Inspect DrawingML preset adjust guides with `ooxml preset-shape `. + +Use schema commands to learn what markup allows. Use prose search to learn what the specification says it +means. Use both when the question asks whether markup is valid and what it does. + +## Report + +Separate schema evidence from specification prose. Keep any edition, profile, source, section, or +application limit shown in the output. If the sources disagree, show the disagreement instead of choosing +silently. diff --git a/skills/research-ooxml/agents/openai.yaml b/skills/research-ooxml/agents/openai.yaml new file mode 100644 index 0000000..e35e4cc --- /dev/null +++ b/skills/research-ooxml/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Research OOXML" + short_description: "Research OOXML rules and schema structure" + default_prompt: "Use $research-ooxml to investigate this OOXML question with the CLI." diff --git a/tests/cli/arguments.test.ts b/tests/cli/arguments.test.ts new file mode 100644 index 0000000..0e63d6d --- /dev/null +++ b/tests/cli/arguments.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import { parseArguments } from "../../apps/cli/src/arguments"; + +test("maps OOXML commands to the internal query operations", () => { + expect(parseArguments(["search", "paragraph spacing", "--part", "1", "--limit", "3"])).toEqual({ + name: "query", + tool: "ooxml_search", + input: { query: "paragraph spacing", part: 1, limit: 3 }, + }); + expect(parseArguments(["element", "w:p"])).toEqual({ + name: "query", + tool: "ooxml_element", + input: { qname: "w:p" }, + }); + expect(parseArguments(["attributes", "w:p", "--profile", "transitional"])).toEqual({ + name: "query", + tool: "ooxml_attributes", + input: { qname: "w:p", profile: "transitional" }, + }); + expect(parseArguments(["namespace", "drawingml"])).toEqual({ + name: "query", + tool: "ooxml_namespace", + input: { query: "drawingml" }, + }); + expect(parseArguments(["package-part", "customXml"])).toEqual({ + name: "query", + tool: "ooxml_package_part", + input: { query: "customXml" }, + }); + expect(parseArguments(["preset-shape", "round2SameRect"])).toEqual({ + name: "query", + tool: "ooxml_preset_shape", + input: { shape: "round2SameRect" }, + }); +}); + +test("rejects invalid numeric options", () => { + expect(() => parseArguments(["search", "paragraph", "--part", "5"])).toThrow( + "--part must be an integer between 1 and 4", + ); + expect(() => parseArguments(["search", "paragraph", "--limit", "0"])).toThrow( + "--limit must be an integer between 1 and 20", + ); +}); + +test("does not expose raw MCP tool calls", () => { + expect(() => parseArguments(["call", "ooxml_element"])).toThrow("Unknown command: call"); + expect(() => parseArguments(["tools"])).toThrow("Unknown command: tools"); +}); + +test("rejects command names inherited from Object", () => { + for (const command of ["toString", "constructor", "__proto__"]) { + expect(() => parseArguments([command, "w:p"])).toThrow(`Unknown command: ${command}`); + } +}); diff --git a/tests/cli/browser-auth.test.ts b/tests/cli/browser-auth.test.ts new file mode 100644 index 0000000..053084a --- /dev/null +++ b/tests/cli/browser-auth.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { + isSafeAuthorizationUrl, + signInMessage, + startOAuthCallback, +} from "../../apps/cli/src/browser-auth"; + +test("only opens secure or loopback authorization URLs", () => { + expect(isSafeAuthorizationUrl(new URL("https://api.ooxml.dev/authorize"))).toBe(true); + expect(isSafeAuthorizationUrl(new URL("http://127.0.0.1:8787/authorize"))).toBe(true); + expect(isSafeAuthorizationUrl(new URL("http://localhost:8787/authorize"))).toBe(true); + expect(isSafeAuthorizationUrl(new URL("http://api.ooxml.dev/authorize"))).toBe(false); + expect(isSafeAuthorizationUrl(new URL("file:///tmp/authorize"))).toBe(false); +}); + +test("fails before sign-in when the callback port is occupied", async () => { + const occupied = createServer(); + await new Promise((resolve) => occupied.listen(0, "127.0.0.1", resolve)); + const port = (occupied.address() as AddressInfo).port; + await expect(startOAuthCallback(port, () => true, 1_000)).rejects.toThrow( + `Could not start the local sign-in callback on port ${port}`, + ); + await new Promise((resolve, reject) => + occupied.close((error) => (error ? reject(error) : resolve())), + ); +}); + +test("ignores callbacks with the wrong state and removes secrets from browser history", async () => { + const callback = await startOAuthCallback(0, (state) => state === "expected", 1_000); + const baseUrl = `http://127.0.0.1:${callback.port}/callback`; + const invalid = await fetch(`${baseUrl}?code=forged&state=wrong`); + expect(invalid.status).toBe(400); + + const valid = await fetch(`${baseUrl}?code=secret-code&state=expected`); + expect(valid.status).toBe(200); + expect(valid.headers.get("cache-control")).toBe("no-store"); + expect(valid.headers.get("referrer-policy")).toBe("no-referrer"); + expect(await valid.text()).toContain('history.replaceState(null,"","/complete")'); + expect(Object.fromEntries(await callback.result)).toEqual({ + code: "secret-code", + state: "expected", + }); +}); + +test("prints a manual sign-in URL before waiting for the browser callback", async () => { + const message = signInMessage(new URL("https://api.ooxml.dev/authorize")); + expect(message).toContain("If it does not open, visit:"); + expect(message).toContain("https://api.ooxml.dev/authorize"); +}); + +test("handles concurrent callbacks without throwing", async () => { + const callback = await startOAuthCallback(0, (state) => state === "expected", 1_000); + const url = `http://127.0.0.1:${callback.port}/callback?code=ok&state=expected`; + const responses = await Promise.all( + Array.from({ length: 20 }, () => fetch(url).catch(() => undefined)), + ); + expect(responses.some((response) => response?.status === 200)).toBe(true); + await callback.result; +}); diff --git a/tests/cli/credentials.test.ts b/tests/cli/credentials.test.ts new file mode 100644 index 0000000..167c58d --- /dev/null +++ b/tests/cli/credentials.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, readFile, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CredentialStore } from "../../apps/cli/src/credentials"; + +test("round-trips credentials and removes them on logout", async () => { + const directory = await mkdtemp(join(tmpdir(), "ooxml-cli-credentials-")); + const path = join(directory, "credentials.json"); + const store = await new CredentialStore(path).open(); + + await store.write({ tokens: { access_token: "secret", token_type: "bearer" } }); + expect(await store.read()).toEqual({ + tokens: { access_token: "secret", token_type: "bearer" }, + }); + expect(await readFile(path, "utf8")).not.toContain("undefined"); + if (process.platform !== "win32") expect((await stat(path)).mode & 0o777).toBe(0o600); + + await store.clear(); + expect(await store.read()).toEqual({}); + await store.close(); +}); + +test("allows only one credential session at a time", async () => { + const directory = await mkdtemp(join(tmpdir(), "ooxml-cli-credentials-")); + const path = join(directory, "credentials.json"); + const first = await new CredentialStore(path).open(); + let secondOpened = false; + const secondPromise = new CredentialStore(path).open().then((session) => { + secondOpened = true; + return session; + }); + + await Bun.sleep(100); + expect(secondOpened).toBe(false); + await first.close(); + const second = await secondPromise; + expect(secondOpened).toBe(true); + await second.close(); +}); diff --git a/tests/cli/oauth-provider.test.ts b/tests/cli/oauth-provider.test.ts new file mode 100644 index 0000000..f65d4b8 --- /dev/null +++ b/tests/cli/oauth-provider.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CredentialStore } from "../../apps/cli/src/credentials"; +import { CliOAuthProvider } from "../../apps/cli/src/oauth-provider"; + +test("validates the OAuth state created for the current sign-in", async () => { + const directory = await mkdtemp(join(tmpdir(), "ooxml-cli-oauth-")); + const store = await new CredentialStore(join(directory, "credentials.json")).open(); + const provider = new CliOAuthProvider("http://127.0.0.1:53682/callback", store); + await provider.load(); + + const state = provider.state(); + expect(provider.validatesState(state)).toBe(true); + expect(provider.validatesState("different")).toBe(false); + expect(provider.validatesState(null)).toBe(false); + await store.close(); +}); + +test("removes the PKCE verifier after saving tokens", async () => { + const directory = await mkdtemp(join(tmpdir(), "ooxml-cli-oauth-")); + const store = await new CredentialStore(join(directory, "credentials.json")).open(); + const provider = new CliOAuthProvider("http://127.0.0.1:53682/callback", store); + await provider.load(); + + await provider.saveCodeVerifier("secret-verifier"); + await provider.saveTokens({ access_token: "access-token", token_type: "bearer" }); + + expect(await store.read()).toEqual({ + tokens: { access_token: "access-token", token_type: "bearer" }, + }); + await store.close(); +});