From ea08f656c43ae2885e0f119f0bb519735035cc5f Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 12 Aug 2026 19:01:43 -0300 Subject: [PATCH 1/5] feat(cli): add authenticated OOXML commands --- .github/workflows/ci.yml | 6 + apps/cli/README.md | 18 +++ apps/cli/package.json | 29 ++++ apps/cli/src/arguments.ts | 169 +++++++++++++++++++++++ apps/cli/src/browser-auth.ts | 85 ++++++++++++ apps/cli/src/cli.ts | 84 +++++++++++ apps/cli/src/constants.ts | 19 +++ apps/cli/src/credentials.ts | 57 ++++++++ apps/cli/src/mcp-client.ts | 57 ++++++++ apps/cli/src/oauth-provider.ts | 109 +++++++++++++++ apps/cli/tsconfig.json | 12 ++ apps/cli/vite.config.ts | 10 ++ bun.lock | 66 ++++++++- package.json | 2 + skills/research-ooxml/SKILL.md | 34 +++++ skills/research-ooxml/agents/openai.yaml | 4 + tests/cli/arguments.test.ts | 49 +++++++ tests/cli/browser-auth.test.ts | 10 ++ tests/cli/credentials.test.ts | 21 +++ tests/cli/oauth-provider.test.ts | 34 +++++ 20 files changed, 874 insertions(+), 1 deletion(-) create mode 100644 apps/cli/README.md create mode 100644 apps/cli/package.json create mode 100644 apps/cli/src/arguments.ts create mode 100644 apps/cli/src/browser-auth.ts create mode 100644 apps/cli/src/cli.ts create mode 100644 apps/cli/src/constants.ts create mode 100644 apps/cli/src/credentials.ts create mode 100644 apps/cli/src/mcp-client.ts create mode 100644 apps/cli/src/oauth-provider.ts create mode 100644 apps/cli/tsconfig.json create mode 100644 apps/cli/vite.config.ts create mode 100644 skills/research-ooxml/SKILL.md create mode 100644 skills/research-ooxml/agents/openai.yaml create mode 100644 tests/cli/arguments.test.ts create mode 100644 tests/cli/browser-auth.test.ts create mode 100644 tests/cli/credentials.test.ts create mode 100644 tests/cli/oauth-provider.test.ts 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..38f2b82 --- /dev/null +++ b/apps/cli/README.md @@ -0,0 +1,18 @@ +# OOXML CLI + +The CLI gives people and agents a stable shell interface to the OOXML reference. It uses Clerk sign-in and the hosted OOXML service. + +```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 +``` + +OAuth tokens stay in a user-only local credentials file. Tool inputs and results are not stored. + +The public commands use OOXML terms. MCP is an internal transport detail and is not part of the CLI contract. + +This package is private while we test the complete login and query flow. Publishing it to npm is a separate release step. diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000..f4b1dbb --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,29 @@ +{ + "name": "@ooxml-dev/cli", + "version": "0.1.0", + "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" + }, + "devDependencies": { + "@types/node": "^25.0.10", + "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..c11afe2 --- /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 (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..eee15aa --- /dev/null +++ b/apps/cli/src/browser-auth.ts @@ -0,0 +1,85 @@ +import { createServer } from "node:http"; +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)) + ); +} + +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."; + return `${title}

${title}

${detail}

${success ? "" : ""}`; +} + +export async function authorizeInBrowser( + provider: CliOAuthProvider, + port: number, + finishAuth: (params: URLSearchParams) => Promise, +): Promise { + const authorizationUrl = provider.pendingAuthorizationUrl; + if (!authorizationUrl) throw new Error("The MCP server did not provide an authorization URL"); + if (!isSafeAuthorizationUrl(authorizationUrl)) { + throw new Error("The MCP server returned an unsafe authorization URL"); + } + + const callback = waitForCallback(port); + callback.catch(() => {}); + console.error("Opening your browser to sign in…"); + try { + await open(authorizationUrl.toString()); + } catch { + console.error(`Open this URL in your browser:\n${authorizationUrl}`); + } + + const params = await callback; + if (params.get("error")) throw new Error("Authorization was denied"); + if (!provider.validatesState(params.get("state"))) { + throw new Error("Authorization callback state did not match"); + } + await finishAuth(params); +} + +function waitForCallback(port: number): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`); + if (url.pathname !== "/callback") { + response.writeHead(404).end(); + return; + } + + const success = Boolean(url.searchParams.get("code")) && !url.searchParams.get("error"); + response.writeHead(success ? 200 : 400, { "Content-Type": "text/html; charset=utf-8" }); + response.end(callbackPage(success)); + settled = true; + clearTimeout(timeout); + server.close(); + resolve(url.searchParams); + }); + + server.on("error", (error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + reject(new Error(`Could not start the OAuth callback on port ${port}`, { cause: error })); + }); + + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + server.close(); + reject(new Error("Timed out waiting for browser sign-in")); + }, CALLBACK_TIMEOUT_MS); + + server.listen(port, "127.0.0.1"); + }); +} diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts new file mode 100644 index 0000000..f70b9c3 --- /dev/null +++ b/apps/cli/src/cli.ts @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +import type { ContentBlock } from "@modelcontextprotocol/client"; +import { parseArguments } from "./arguments.js"; +import { callbackPort, CLI_VERSION, mcpUrl } from "./constants.js"; +import { CredentialStore } from "./credentials.js"; +import { connectToMcp } from "./mcp-client.js"; + +const HELP = `OOXML reference tools for people and agents. + +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(), + serverUrl: mcpUrl(), + }); + 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") { + await new CredentialStore().clear(); + console.log("Signed out on this device."); + 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..bc79ec7 --- /dev/null +++ b/apps/cli/src/constants.ts @@ -0,0 +1,19 @@ +export const CLI_NAME = "ooxml"; +export const CLI_VERSION = "0.1.0"; +export const DEFAULT_MCP_URL = "https://api.ooxml.dev/mcp"; +export const DEFAULT_CALLBACK_PORT = 53_682; + +export function mcpUrl(): URL { + return new URL(process.env.OOXML_MCP_URL ?? DEFAULT_MCP_URL); +} + +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..2b798dd --- /dev/null +++ b/apps/cli/src/credentials.ts @@ -0,0 +1,57 @@ +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"; + +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 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 directory = dirname(this.path); + await mkdir(directory, { recursive: true, mode: 0o700 }); + if (process.platform !== "win32") await chmod(directory, 0o700); + + 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 }); + } +} diff --git a/apps/cli/src/mcp-client.ts b/apps/cli/src/mcp-client.ts new file mode 100644 index 0000000..64f5c98 --- /dev/null +++ b/apps/cli/src/mcp-client.ts @@ -0,0 +1,57 @@ +import { + Client, + StreamableHTTPClientTransport, + UnauthorizedError, +} from "@modelcontextprotocol/client"; +import { authorizeInBrowser } from "./browser-auth.js"; +import { CLI_NAME, CLI_VERSION } from "./constants.js"; +import { CredentialStore } from "./credentials.js"; +import { CliOAuthProvider } from "./oauth-provider.js"; + +interface ConnectOptions { + allowBrowser: boolean; + callbackPort: number; + serverUrl: URL; +} + +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 provider = new CliOAuthProvider(redirectUrl, new CredentialStore()); + await provider.load(); + + let client = newClient(); + let transport = newTransport(options.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(options.serverUrl, provider); + await client.connect(transport); + } + + return { + client, + close: () => client.close(), + }; +} diff --git a/apps/cli/src/oauth-provider.ts b/apps/cli/src/oauth-provider.ts new file mode 100644 index 0000000..78757d3 --- /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 { CredentialStore, 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: CredentialStore, + ) { + 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("OAuth code verifier 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..983f2b3 100644 --- a/bun.lock +++ b/bun.lock @@ -12,9 +12,25 @@ "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", + }, + "devDependencies": { + "@types/node": "^25.0.10", + "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 +329,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 +339,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"], @@ -745,6 +765,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 +797,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 +807,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 +845,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 +913,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 +1099,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 +1115,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 +1133,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,6 +1153,8 @@ "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=="], @@ -1145,6 +1199,8 @@ "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=="], + "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,6 +1215,10 @@ "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=="], @@ -1253,6 +1313,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 +1323,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=="], 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..fffde82 --- /dev/null +++ b/skills/research-ooxml/SKILL.md @@ -0,0 +1,34 @@ +--- +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. Treat its output as reference evidence, not permission to edit files. + +## Start + +Run `ooxml --help` to confirm the CLI is installed. If a query says the user is not signed in, run +`ooxml login` and let the user finish the browser flow. + +## Choose evidence + +- Search prose with `ooxml search ""`. Use `--part` only when the relevant ECMA part is known. +- Fetch 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 ` and enum 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 for what markup allows. Use prose search for meaning and documented behavior. Use both +when the question asks whether markup is valid and what it does. + +## Report + +State which evidence came from schema lookup and which came from specification prose. Preserve edition, +profile, source, section, and application-specific limits shown in the output. If the surfaces 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..cf0f5c6 --- /dev/null +++ b/tests/cli/arguments.test.ts @@ -0,0 +1,49 @@ +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"); +}); diff --git a/tests/cli/browser-auth.test.ts b/tests/cli/browser-auth.test.ts new file mode 100644 index 0000000..20abf97 --- /dev/null +++ b/tests/cli/browser-auth.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "bun:test"; +import { isSafeAuthorizationUrl } 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); +}); diff --git a/tests/cli/credentials.test.ts b/tests/cli/credentials.test.ts new file mode 100644 index 0000000..54e6171 --- /dev/null +++ b/tests/cli/credentials.test.ts @@ -0,0 +1,21 @@ +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 = new CredentialStore(path); + + 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({}); +}); diff --git a/tests/cli/oauth-provider.test.ts b/tests/cli/oauth-provider.test.ts new file mode 100644 index 0000000..52a92e2 --- /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 provider = new CliOAuthProvider( + "http://127.0.0.1:53682/callback", + new CredentialStore(join(directory, "credentials.json")), + ); + 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); +}); + +test("removes the PKCE verifier after saving tokens", async () => { + const directory = await mkdtemp(join(tmpdir(), "ooxml-cli-oauth-")); + const store = new CredentialStore(join(directory, "credentials.json")); + 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" }, + }); +}); From a084f846b78122aa26305587bb069593a0d110fa Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 12 Aug 2026 19:08:45 -0300 Subject: [PATCH 2/5] docs(cli): clarify commands and sign-in --- apps/cli/README.md | 12 ++++++++---- apps/cli/package.json | 1 + apps/cli/src/browser-auth.ts | 12 +++++++----- apps/cli/src/oauth-provider.ts | 2 +- skills/research-ooxml/SKILL.md | 21 +++++++++++---------- 5 files changed, 28 insertions(+), 20 deletions(-) diff --git a/apps/cli/README.md b/apps/cli/README.md index 38f2b82..8551082 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,6 +1,8 @@ # OOXML CLI -The CLI gives people and agents a stable shell interface to the OOXML reference. It uses Clerk sign-in and the hosted OOXML service. +Use the OOXML reference from a terminal or an agent skill. The CLI signs in with Clerk and sends queries to the hosted ooxml.dev service. + +While the package is private, run it from the repository root: ```bash bun run ooxml login @@ -11,8 +13,10 @@ bun run ooxml attributes w:p bun run ooxml logout ``` -OAuth tokens stay in a user-only local credentials file. Tool inputs and results are not stored. +For this private test, sign-in credentials are stored in the current user's application data directory. The CLI does not store query inputs or results. + +The bundled [`research-ooxml`](../../skills/research-ooxml/SKILL.md) skill helps agents choose the right commands and combine schema and specification evidence. -The public commands use OOXML terms. MCP is an internal transport detail and is not part of the CLI contract. +MCP is an internal transport detail. It is not part of the CLI or skill interface. -This package is private while we test the complete login and query flow. Publishing it to npm is a separate release step. +The package is private for this first test. Moving credentials to the operating system's secure store and publishing to npm are separate release steps. diff --git a/apps/cli/package.json b/apps/cli/package.json index f4b1dbb..a1c63cb 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,7 @@ { "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": { diff --git a/apps/cli/src/browser-auth.ts b/apps/cli/src/browser-auth.ts index eee15aa..2630bb4 100644 --- a/apps/cli/src/browser-auth.ts +++ b/apps/cli/src/browser-auth.ts @@ -25,9 +25,9 @@ export async function authorizeInBrowser( finishAuth: (params: URLSearchParams) => Promise, ): Promise { const authorizationUrl = provider.pendingAuthorizationUrl; - if (!authorizationUrl) throw new Error("The MCP server did not provide an authorization URL"); + if (!authorizationUrl) throw new Error("The OOXML service did not provide a sign-in URL"); if (!isSafeAuthorizationUrl(authorizationUrl)) { - throw new Error("The MCP server returned an unsafe authorization URL"); + throw new Error("The OOXML service returned an unsafe sign-in URL"); } const callback = waitForCallback(port); @@ -40,9 +40,9 @@ export async function authorizeInBrowser( } const params = await callback; - if (params.get("error")) throw new Error("Authorization was denied"); + if (params.get("error")) throw new Error("Sign-in was canceled or denied"); if (!provider.validatesState(params.get("state"))) { - throw new Error("Authorization callback state did not match"); + throw new Error("Sign-in could not be verified. Try again."); } await finishAuth(params); } @@ -70,7 +70,9 @@ function waitForCallback(port: number): Promise { if (settled) return; settled = true; clearTimeout(timeout); - reject(new Error(`Could not start the OAuth callback on port ${port}`, { cause: error })); + reject( + new Error(`Could not start the local sign-in callback on port ${port}`, { cause: error }), + ); }); const timeout = setTimeout(() => { diff --git a/apps/cli/src/oauth-provider.ts b/apps/cli/src/oauth-provider.ts index 78757d3..8dbbae0 100644 --- a/apps/cli/src/oauth-provider.ts +++ b/apps/cli/src/oauth-provider.ts @@ -80,7 +80,7 @@ export class CliOAuthProvider implements OAuthClientProvider { } codeVerifier(): string { - if (!this.credentials.verifier) throw new Error("OAuth code verifier is missing"); + if (!this.credentials.verifier) throw new Error("Sign-in session data is missing"); return this.credentials.verifier; } diff --git a/skills/research-ooxml/SKILL.md b/skills/research-ooxml/SKILL.md index fffde82..7787dab 100644 --- a/skills/research-ooxml/SKILL.md +++ b/skills/research-ooxml/SKILL.md @@ -5,30 +5,31 @@ description: Research published OOXML specification text, schema structure, name # Research OOXML -Use the `ooxml` CLI. Treat its output as reference evidence, not permission to edit files. +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 query says the user is not signed in, run -`ooxml login` and let the user finish the browser flow. +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. -- Fetch a known section with `ooxml section [--part <1-4>]`. +- 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 ` and enum values with `ooxml enum `. +- 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 for what markup allows. Use prose search for meaning and documented behavior. Use both -when the question asks whether markup is valid and what it does. +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 -State which evidence came from schema lookup and which came from specification prose. Preserve edition, -profile, source, section, and application-specific limits shown in the output. If the surfaces disagree, -show the disagreement instead of choosing silently. +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. From d06d5713dea368cf0de43d4995d4432819aadb1f Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 12 Aug 2026 19:37:10 -0300 Subject: [PATCH 3/5] fix(cli): protect sign-in and credentials --- apps/cli/README.md | 8 +- apps/cli/package.json | 6 +- apps/cli/src/arguments.ts | 2 +- apps/cli/src/browser-auth.ts | 136 +++++++++++++++++++++---------- apps/cli/src/cli.ts | 14 ++-- apps/cli/src/constants.ts | 6 +- apps/cli/src/credentials.ts | 33 +++++++- apps/cli/src/mcp-client.ts | 58 +++++++------ apps/cli/src/oauth-provider.ts | 4 +- bun.lock | 24 ++++-- tests/cli/arguments.test.ts | 6 ++ tests/cli/browser-auth.test.ts | 33 +++++++- tests/cli/credentials.test.ts | 21 ++++- tests/cli/oauth-provider.test.ts | 10 +-- 14 files changed, 259 insertions(+), 102 deletions(-) diff --git a/apps/cli/README.md b/apps/cli/README.md index 8551082..0576fa9 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,6 +1,6 @@ # OOXML CLI -Use the OOXML reference from a terminal or an agent skill. The CLI signs in with Clerk and sends queries to the hosted ooxml.dev service. +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: @@ -13,10 +13,10 @@ bun run ooxml attributes w:p bun run ooxml logout ``` -For this private test, sign-in credentials are stored in the current user's application data directory. The CLI does not store query inputs or results. +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 helps agents choose the right commands and combine schema and specification evidence. +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 is private for this first test. Moving credentials to the operating system's secure store and publishing to npm are separate release steps. +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 index a1c63cb..22bfcb8 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -20,10 +20,12 @@ }, "dependencies": { "@modelcontextprotocol/client": "2.0.0", - "open": "11.0.0" + "open": "11.0.0", + "proper-lockfile": "4.1.2" }, "devDependencies": { - "@types/node": "^25.0.10", + "@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 index c11afe2..f3b88a3 100644 --- a/apps/cli/src/arguments.ts +++ b/apps/cli/src/arguments.ts @@ -104,7 +104,7 @@ export function parseArguments(args: string[]): OoxmlCommand { attributes: "ooxml_attributes", enum: "ooxml_enum", }; - if (qnameTools[command]) { + if (Object.hasOwn(qnameTools, command)) { const parsed = singleValue(rest, `ooxml ${command} [--profile ]`, [ "--profile", ]); diff --git a/apps/cli/src/browser-auth.ts b/apps/cli/src/browser-auth.ts index 2630bb4..d08e419 100644 --- a/apps/cli/src/browser-auth.ts +++ b/apps/cli/src/browser-auth.ts @@ -1,4 +1,5 @@ import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; import open from "open"; import type { CliOAuthProvider } from "./oauth-provider.js"; @@ -16,7 +17,96 @@ function callbackPage(success: boolean): string { const detail = success ? "You can close this window and return to the terminal." : "Return to the terminal and try again."; - return `${title}

${title}

${detail}

${success ? "" : ""}`; + 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) => { + 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( @@ -30,8 +120,7 @@ export async function authorizeInBrowser( throw new Error("The OOXML service returned an unsafe sign-in URL"); } - const callback = waitForCallback(port); - callback.catch(() => {}); + const callback = await startOAuthCallback(port, (state) => provider.validatesState(state)); console.error("Opening your browser to sign in…"); try { await open(authorizationUrl.toString()); @@ -39,49 +128,10 @@ export async function authorizeInBrowser( console.error(`Open this URL in your browser:\n${authorizationUrl}`); } - const params = await callback; + 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); } - -function waitForCallback(port: number): Promise { - return new Promise((resolve, reject) => { - let settled = false; - const server = createServer((request, response) => { - const url = new URL(request.url ?? "/", `http://127.0.0.1:${port}`); - if (url.pathname !== "/callback") { - response.writeHead(404).end(); - return; - } - - const success = Boolean(url.searchParams.get("code")) && !url.searchParams.get("error"); - response.writeHead(success ? 200 : 400, { "Content-Type": "text/html; charset=utf-8" }); - response.end(callbackPage(success)); - settled = true; - clearTimeout(timeout); - server.close(); - resolve(url.searchParams); - }); - - server.on("error", (error) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - reject( - new Error(`Could not start the local sign-in callback on port ${port}`, { cause: error }), - ); - }); - - const timeout = setTimeout(() => { - if (settled) return; - settled = true; - server.close(); - reject(new Error("Timed out waiting for browser sign-in")); - }, CALLBACK_TIMEOUT_MS); - - server.listen(port, "127.0.0.1"); - }); -} diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index f70b9c3..a7304e3 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -2,11 +2,11 @@ import type { ContentBlock } from "@modelcontextprotocol/client"; import { parseArguments } from "./arguments.js"; -import { callbackPort, CLI_VERSION, mcpUrl } from "./constants.js"; +import { callbackPort, CLI_VERSION } from "./constants.js"; import { CredentialStore } from "./credentials.js"; import { connectToMcp } from "./mcp-client.js"; -const HELP = `OOXML reference tools for people and agents. +const HELP = `Search and inspect the OOXML reference. Usage: ooxml login @@ -33,7 +33,6 @@ async function withClient( const connection = await connectToMcp({ allowBrowser, callbackPort: callbackPort(), - serverUrl: mcpUrl(), }); try { return await callback(connection.client); @@ -60,8 +59,13 @@ async function main(): Promise { return; } if (command.name === "logout") { - await new CredentialStore().clear(); - console.log("Signed out on this device."); + 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") { diff --git a/apps/cli/src/constants.ts b/apps/cli/src/constants.ts index bc79ec7..aeea5ff 100644 --- a/apps/cli/src/constants.ts +++ b/apps/cli/src/constants.ts @@ -1,12 +1,8 @@ export const CLI_NAME = "ooxml"; export const CLI_VERSION = "0.1.0"; -export const DEFAULT_MCP_URL = "https://api.ooxml.dev/mcp"; +export const MCP_URL = "https://api.ooxml.dev/mcp"; export const DEFAULT_CALLBACK_PORT = 53_682; -export function mcpUrl(): URL { - return new URL(process.env.OOXML_MCP_URL ?? DEFAULT_MCP_URL); -} - export function callbackPort(): number { const rawPort = process.env.OOXML_CALLBACK_PORT; if (!rawPort) return DEFAULT_CALLBACK_PORT; diff --git a/apps/cli/src/credentials.ts b/apps/cli/src/credentials.ts index 2b798dd..7314788 100644 --- a/apps/cli/src/credentials.ts +++ b/apps/cli/src/credentials.ts @@ -6,6 +6,7 @@ import type { StoredOAuthClientInformation, StoredOAuthTokens, } from "@modelcontextprotocol/client"; +import { lock as lockFile } from "proper-lockfile"; export interface StoredCredentials { clientInformation?: StoredOAuthClientInformation; @@ -31,6 +32,30 @@ export function credentialsPath(): string { 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; @@ -41,10 +66,6 @@ export class CredentialStore { } async write(credentials: StoredCredentials): Promise { - const directory = dirname(this.path); - await mkdir(directory, { recursive: true, mode: 0o700 }); - if (process.platform !== "win32") await chmod(directory, 0o700); - 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); @@ -54,4 +75,8 @@ export class CredentialStore { 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 index 64f5c98..07a26ab 100644 --- a/apps/cli/src/mcp-client.ts +++ b/apps/cli/src/mcp-client.ts @@ -4,14 +4,13 @@ import { UnauthorizedError, } from "@modelcontextprotocol/client"; import { authorizeInBrowser } from "./browser-auth.js"; -import { CLI_NAME, CLI_VERSION } from "./constants.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; - serverUrl: URL; } export interface ConnectedMcpClient { @@ -29,29 +28,42 @@ function newTransport(serverUrl: URL, provider: CliOAuthProvider): StreamableHTT export async function connectToMcp(options: ConnectOptions): Promise { const redirectUrl = `http://127.0.0.1:${options.callbackPort}/callback`; - const provider = new CliOAuthProvider(redirectUrl, new CredentialStore()); - await provider.load(); - - let client = newClient(); - let transport = newTransport(options.serverUrl, provider); + const serverUrl = new URL(MCP_URL); + const credentials = await new CredentialStore().open(); 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."); + 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); } - await authorizeInBrowser(provider, options.callbackPort, (params) => - transport.finishAuth(params), - ); - client = newClient(); - transport = newTransport(options.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; } - - return { - client, - close: () => client.close(), - }; } diff --git a/apps/cli/src/oauth-provider.ts b/apps/cli/src/oauth-provider.ts index 8dbbae0..7d3415f 100644 --- a/apps/cli/src/oauth-provider.ts +++ b/apps/cli/src/oauth-provider.ts @@ -7,7 +7,7 @@ import type { StoredOAuthClientInformation, StoredOAuthTokens, } from "@modelcontextprotocol/client"; -import { CredentialStore, type StoredCredentials } from "./credentials.js"; +import { type CredentialSession, type StoredCredentials } from "./credentials.js"; export class CliOAuthProvider implements OAuthClientProvider { pendingAuthorizationUrl?: URL; @@ -18,7 +18,7 @@ export class CliOAuthProvider implements OAuthClientProvider { constructor( readonly redirectUrl: string, - private readonly store: CredentialStore, + private readonly store: CredentialSession, ) { this.clientMetadata = { client_name: "OOXML CLI", diff --git a/bun.lock b/bun.lock index 983f2b3..faef7e4 100644 --- a/bun.lock +++ b/bun.lock @@ -21,9 +21,11 @@ "dependencies": { "@modelcontextprotocol/client": "2.0.0", "open": "11.0.0", + "proper-lockfile": "4.1.2", }, "devDependencies": { - "@types/node": "^25.0.10", + "@types/node": "^20.19.0", + "@types/proper-lockfile": "^4.1.4", "typescript": "catalog:", "vite-plus": "catalog:", }, @@ -611,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=="], @@ -1159,6 +1165,8 @@ "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=="], @@ -1199,6 +1207,8 @@ "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=="], @@ -1225,6 +1235,8 @@ "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=="], @@ -1279,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=="], @@ -1353,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=="], @@ -1387,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=="], @@ -1410,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/tests/cli/arguments.test.ts b/tests/cli/arguments.test.ts index cf0f5c6..0e63d6d 100644 --- a/tests/cli/arguments.test.ts +++ b/tests/cli/arguments.test.ts @@ -47,3 +47,9 @@ 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 index 20abf97..b31d57f 100644 --- a/tests/cli/browser-auth.test.ts +++ b/tests/cli/browser-auth.test.ts @@ -1,5 +1,7 @@ import { expect, test } from "bun:test"; -import { isSafeAuthorizationUrl } from "../../apps/cli/src/browser-auth"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { isSafeAuthorizationUrl, 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); @@ -8,3 +10,32 @@ test("only opens secure or loopback authorization URLs", () => { 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", + }); +}); diff --git a/tests/cli/credentials.test.ts b/tests/cli/credentials.test.ts index 54e6171..167c58d 100644 --- a/tests/cli/credentials.test.ts +++ b/tests/cli/credentials.test.ts @@ -7,7 +7,7 @@ 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 = new CredentialStore(path); + const store = await new CredentialStore(path).open(); await store.write({ tokens: { access_token: "secret", token_type: "bearer" } }); expect(await store.read()).toEqual({ @@ -18,4 +18,23 @@ test("round-trips credentials and removes them on logout", async () => { 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 index 52a92e2..f65d4b8 100644 --- a/tests/cli/oauth-provider.test.ts +++ b/tests/cli/oauth-provider.test.ts @@ -7,21 +7,20 @@ 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 provider = new CliOAuthProvider( - "http://127.0.0.1:53682/callback", - new CredentialStore(join(directory, "credentials.json")), - ); + 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 = new CredentialStore(join(directory, "credentials.json")); + 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(); @@ -31,4 +30,5 @@ test("removes the PKCE verifier after saving tokens", async () => { expect(await store.read()).toEqual({ tokens: { access_token: "access-token", token_type: "bearer" }, }); + await store.close(); }); From 4407a8e98d4f41fb908915d6aae35b262150e220 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 12 Aug 2026 19:38:56 -0300 Subject: [PATCH 4/5] fix(cli): show manual sign-in URL --- apps/cli/src/browser-auth.ts | 10 ++++++---- tests/cli/browser-auth.test.ts | 12 +++++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/browser-auth.ts b/apps/cli/src/browser-auth.ts index d08e419..72b262a 100644 --- a/apps/cli/src/browser-auth.ts +++ b/apps/cli/src/browser-auth.ts @@ -12,6 +12,10 @@ export function isSafeAuthorizationUrl(url: URL): boolean { ); } +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 @@ -121,12 +125,10 @@ export async function authorizeInBrowser( } const callback = await startOAuthCallback(port, (state) => provider.validatesState(state)); - console.error("Opening your browser to sign in…"); + console.error(signInMessage(authorizationUrl)); try { await open(authorizationUrl.toString()); - } catch { - console.error(`Open this URL in your browser:\n${authorizationUrl}`); - } + } catch {} const params = await callback.result; if (params.get("error")) throw new Error("Sign-in was canceled or denied"); diff --git a/tests/cli/browser-auth.test.ts b/tests/cli/browser-auth.test.ts index b31d57f..9c00484 100644 --- a/tests/cli/browser-auth.test.ts +++ b/tests/cli/browser-auth.test.ts @@ -1,7 +1,11 @@ import { expect, test } from "bun:test"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; -import { isSafeAuthorizationUrl, startOAuthCallback } from "../../apps/cli/src/browser-auth"; +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); @@ -39,3 +43,9 @@ test("ignores callbacks with the wrong state and removes secrets from browser hi 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"); +}); From b3cd81c7bc3d6012a806da977d291ee9847146a3 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 12 Aug 2026 19:52:46 -0300 Subject: [PATCH 5/5] fix(cli): handle duplicate auth callbacks --- apps/cli/src/browser-auth.ts | 4 ++++ tests/cli/browser-auth.test.ts | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/apps/cli/src/browser-auth.ts b/apps/cli/src/browser-auth.ts index 72b262a..89fba4b 100644 --- a/apps/cli/src/browser-auth.ts +++ b/apps/cli/src/browser-auth.ts @@ -46,6 +46,10 @@ export async function startOAuthCallback( 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) { diff --git a/tests/cli/browser-auth.test.ts b/tests/cli/browser-auth.test.ts index 9c00484..053084a 100644 --- a/tests/cli/browser-auth.test.ts +++ b/tests/cli/browser-auth.test.ts @@ -49,3 +49,13 @@ test("prints a manual sign-in URL before waiting for the browser callback", asyn 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; +});