typst-mcp 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dang Duy Toan (caphefalumi)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE(FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,167 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # Typst Web MCP (`typst-web-mcp`)
4
+
5
+ **Model Context Protocol (MCP) Server for [`https://typst.app`](https://typst.app) Cloud Collaboration, Real-Time Yjs CRDT Editing & Compiler Diagnostics**
6
+
7
+ [![npm version](https://img.shields.io/npm/v/typst-mcp.svg?style=flat-square)](https://www.npmjs.com/package/typst-mcp)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](./LICENSE)
9
+ [![Node.js Package](https://github.com/caphefalumi/typst-web-mcp/actions/workflows/npm-publish.yml/badge.svg)](https://github.com/caphefalumi/typst-web-mcp/actions/workflows/npm-publish.yml)
10
+ [![Runtime: Bun](https://img.shields.io/badge/Runtime-Bun_%E2%89%A51.2-f9f1e1?style=flat-square&logo=bun&logoColor=black)](https://bun.sh)
11
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.8-3178C6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
12
+ [![MCP SDK](https://img.shields.io/badge/MCP-2024--11--05-10a37f?style=flat-square)](https://modelcontextprotocol.io)
13
+ [![Protocol: Yjs + lib0](https://img.shields.io/badge/CRDT-Yjs_%2B_lib0_v3-ff69b4?style=flat-square)](./docs/PROTOCOL.md)
14
+ [![Tests](https://img.shields.io/badge/Tests-7_Passing_(57_Assertions)-2ea44f?style=flat-square)](./test/typst-mcp.test.ts)
15
+
16
+ </div>
17
+
18
+ ---
19
+
20
+ ## Overview
21
+
22
+ `typst-web-mcp` connects AI coding and writing agents directly to collaborative [**typst.app**](https://typst.app) cloud projects. Instead of relying on brittle browser DOM scraping, it implements the native `typst.app` client protocol:
23
+
24
+ - **Native Yjs CRDT Text Sync:** Decodes and mutates multiplayer `Y.Doc` (`doc.getText("text")`) state vectors so surgical edits preserve collaborator cursors and version history.
25
+ - **Binary `/editing/v3` WebSocket Wire Protocol:** Encodes and decodes `lib0` variable-length binary frames (`Subscribe`, `TextUpdate`, `Pong`, `Refresh`, `Presence`, `Cursor`, `Leave`) with REST `/v1/files/:projectId/:fileId/yjs` persistence.
26
+ - **GraphQL & REST Project Management:** Full project CRUD, file tree inspection, rename/move, delete, and binary figure/asset uploads (`PNG`, `JPG`, `SVG`, `PDF`, fonts).
27
+ - **Direct Authentication + Fallback:** Authenticates via `GET /v1/to-signin` + `POST /v1/auth/signin` (`__Http-typst-session`), with persistent session caching and automatic cookie/Playwright fallback on challenge.
28
+ - **Compiler Diagnostics, Preview Screenshots & PDF Export:** Synchronizes cloud Yjs text and binary assets into an isolated workspace and runs the Typst compiler to return structured line/column diagnostics, high-DPI PNG preview screenshots (`typst_capture_screenshot`), and compiled PDFs (`typst_export_pdf`).
29
+
30
+ ---
31
+
32
+ ## Architecture
33
+
34
+ ```mermaid
35
+ flowchart LR
36
+ Agent["AI Agent\n(Pi / Claude / Cursor)"] <-->|"JSON-RPC (stdio)"| MCP["typst-web-mcp\n(Bun / TypeScript)"]
37
+ MCP <-->|"GraphQL (/v1/graphql)\n+ X-Typst-Preflight"| API["https://api.typst.app"]
38
+ MCP <-->|"REST Yjs & Blobs\n(/v1/files/:pid/:fid/yjs)"| API
39
+ MCP <-->|"Binary lib0 WebSocket\n(wss://api.typst.app/editing/v3)"| WS["typst.app Real-Time Sync"]
40
+ MCP -->|"Workspace Sync + Compile"| TypstCLI["Typst Compiler\n(Diagnostics & PDF)"]
41
+ ```
42
+
43
+ See [`docs/PROTOCOL.md`](./docs/PROTOCOL.md) for the complete reverse-engineered protocol specification.
44
+
45
+ ---
46
+
47
+ ## MCP Tools (16 Tools)
48
+
49
+ | Category | Tool Name | Description |
50
+ |:---|:---|:---|
51
+ | **Auth** | `typst_auth_login` | Authenticate with `https://typst.app` via email/password (`POST /v1/auth/signin`) or `__Http-typst-session` cookie, with browser fallback. |
52
+ | **Auth** | `typst_auth_status` | Check active authentication state, login method, and stored session cookies. |
53
+ | **Projects** | `typst_list_projects` | List all owned and shared/external projects in the user's `typst.app` dashboard (`DashboardContent`). |
54
+ | **Projects** | `typst_get_project` | Retrieve full project metadata, access permissions, pinned compiler version, and file tree (`ProjectFiles`). |
55
+ | **Projects** | `typst_create_project` | Create a new `typst.app` cloud project with initial `.typ`/`.bib` files and pinned compiler version. |
56
+ | **Projects** | `typst_delete_project` | Permanently delete a `typst.app` project by ID (`DeleteProjectMutation`). |
57
+ | **Files & CRDT** | `typst_list_files` | List all multiplayer Yjs text files and binary asset blobs in a project. |
58
+ | **Files & CRDT** | `typst_read_file` | Read any project file — decodes binary Yjs CRDT state (`doc.getText("text")`) into UTF-8 text or base64 for binary assets. |
59
+ | **Files & CRDT** | `typst_write_file` | Create or overwrite a `.typ`/`.bib`/`.toml` file, syncing Yjs state diffs over `/editing/v3` WebSocket and REST. |
60
+ | **Files & CRDT** | `typst_edit_file` | Apply non-overlapping surgical `oldText -> newText` replacements directly to the `Y.Text("text")` CRDT. |
61
+ | **Assets & Tree** | `typst_upload_asset` | Upload or replace binary assets (`PNG`, `JPG`, `SVG`, `PDF`, fonts) from a local path or base64 payload. |
62
+ | **Assets & Tree** | `typst_rename_file` | Rename or move a file inside a `typst.app` project (`UpdatesFileMutation`). |
63
+ | **Assets & Tree** | `typst_delete_file` | Delete a file or binary asset from a project (`DeleteFilesMutation`). |
64
+ | **Compiler** | `typst_get_diagnostics` | Sync all cloud files and run `typst compile` to return structured errors, warnings, line/column spans, and hints. |
65
+ | **Compiler** | `typst_export_pdf` | Sync all cloud text & binary assets, compile the project to PDF, and save to `outputPath`. |
66
+ | **Preview** | `typst_capture_screenshot` | Capture high-DPI PNG preview screenshots (`mode="pages"` renders compiled PNG pages; `mode="browser"` screenshots the live `typst.app` web UI). |
67
+
68
+ ---
69
+
70
+ ## Quick Start
71
+
72
+ ### Prerequisites
73
+
74
+ - [**Node.js**](https://nodejs.org/) `>= 18` (for `npx` install) or [**Bun**](https://bun.sh) `>= 1.2` (for local development)
75
+ - [**Typst CLI**](https://github.com/typst/typst) (`typst` on `PATH` for `typst_get_diagnostics` and `typst_export_pdf`)
76
+
77
+ ### Installation
78
+
79
+ **Recommended — no clone, no global install:**
80
+
81
+ ```bash
82
+ npx -y typst-mcp
83
+ ```
84
+
85
+ Use that same `npx -y typst-mcp` command in MCP config (see below). `-y` skips the install prompt.
86
+
87
+ **From source (development):**
88
+
89
+ ```bash
90
+ git clone https://github.com/caphefalumi/typst-web-mcp.git
91
+ cd typst-web-mcp
92
+ bun install
93
+ bun run dev
94
+ ```
95
+
96
+ ### MCP Client Configuration
97
+
98
+ Add `typst-mcp` to your MCP client configuration (`.pi/agent/mcp-adapter.json`, `claude_desktop_config.json`, or `.cursor/mcp.json`):
99
+
100
+ ```json
101
+ {
102
+ "mcpServers": {
103
+ "typst-mcp": {
104
+ "command": "npx",
105
+ "args": ["-y", "typst-mcp"],
106
+ "env": {
107
+ "TYPST_EMAIL": "your-email@example.com",
108
+ "TYPST_PASSWORD": "your-password"
109
+ }
110
+ }
111
+ }
112
+ }
113
+ ```
114
+
115
+ > **Session Persistence:** Once authenticated via `typst_auth_login` (or environment variables), the `__Http-typst-session` cookie is cached locally at `~/.pi/agent/typst-mcp-session.json` so subsequent tool calls authenticate automatically without re-signing in.
116
+
117
+ ---
118
+
119
+ ## Development & Verification
120
+
121
+ ```bash
122
+ # Compile publishable output to dist/
123
+ bun run build
124
+
125
+ # Typecheck with strict TypeScript
126
+ bun run typecheck
127
+
128
+ # Run full unit, mock server, and live api.typst.app protocol test suite
129
+ bun test
130
+ ```
131
+
132
+ ### Publishing to npm
133
+
134
+ Matches [Canvas-CLI](https://github.com/caphefalumi/Canvas-CLI): GitHub **Release created** (or manual **workflow_dispatch**) runs [`.github/workflows/npm-publish.yml`](./.github/workflows/npm-publish.yml) — `bun ci`, `bun run build`, `npm publish` with OIDC environment `npm-publish` on npmjs.com.
135
+
136
+ **One-time (new package name):** on [npmjs.com](https://www.npmjs.com) add a **Trusted Publisher** for package `typst-mcp` — GitHub Actions, org/user `caphefalumi`, repo `typst-web-mcp`, workflow `npm-publish.yml`, environment `npm-publish`, allow **npm publish**. First successful CI publish must happen within 2 days of creating that config.
137
+
138
+ ### Project Structure
139
+
140
+ ```text
141
+ typst-web-mcp/
142
+ ├── docs/
143
+ │ └── PROTOCOL.md # Reverse-engineered typst.app REST, GraphQL, Yjs & WS v3 spec
144
+ ├── src/
145
+ │ ├── protocol/
146
+ │ │ ├── ws-codec.ts # Binary lib0 /editing/v3 WebSocket encoder & decoder
147
+ │ │ ├── yjs-doc.ts # Yjs CRDT text decoding, replacement & surgical patching
148
+ │ │ └── graphql.ts # GraphQL queries, mutations, fragments & types
149
+ │ ├── client/
150
+ │ │ ├── auth.ts # Direct /v1/auth/signin login, cookie jar & Playwright fallback
151
+ │ │ ├── compiler.ts # Typst binary resolver, diagnostic parser, PDF & PNG preview compiler
152
+ │ │ ├── errors.ts # Structured TypstLoadError & error formatting helpers
153
+ │ │ └── typst-client.ts # TypstCloudClient (REST, GraphQL, WebSocket v3, Compiler facade)
154
+ │ ├── tools/
155
+ │ │ └── definitions.ts # MCP JSON Schema definitions for all 16 tools
156
+ │ └── index.ts # MCP stdio server entrypoint & tool dispatcher
157
+ ├── test/
158
+ │ └── typst-mcp.test.ts # 7 test suites (57 assertions) covering codecs, CRDTs & E2E
159
+ ├── LICENSE # MIT License
160
+ └── package.json
161
+ ```
162
+
163
+ ---
164
+
165
+ ## License
166
+
167
+ Released under the [MIT License](./LICENSE). Copyright (c) 2026 Dang Duy Toan (`caphefalumi`).
@@ -0,0 +1,310 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ const DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36";
5
+ function extractRawSetCookies(headers) {
6
+ const headerWithGetSetCookie = headers;
7
+ if (typeof headerWithGetSetCookie.getSetCookie === "function") {
8
+ return headerWithGetSetCookie.getSetCookie();
9
+ }
10
+ const singleCookie = headers.get("set-cookie");
11
+ if (singleCookie) {
12
+ return [singleCookie];
13
+ }
14
+ return [];
15
+ }
16
+ function resolveSessionFilePath(configuredPath) {
17
+ if (configuredPath === undefined) {
18
+ return join(homedir(), ".pi", "agent", "typst-mcp-session.json");
19
+ }
20
+ return configuredPath;
21
+ }
22
+ export function parseSetCookieHeaders(headers) {
23
+ const result = {};
24
+ const rawList = extractRawSetCookies(headers);
25
+ for (const raw of rawList) {
26
+ const firstPart = raw.split(";")[0]?.trim();
27
+ if (!firstPart) {
28
+ continue;
29
+ }
30
+ const eqIdx = firstPart.indexOf("=");
31
+ if (eqIdx <= 0) {
32
+ continue;
33
+ }
34
+ const name = firstPart.slice(0, eqIdx).trim();
35
+ const val = firstPart.slice(eqIdx + 1).trim();
36
+ if (val === "") {
37
+ delete result[name];
38
+ }
39
+ else {
40
+ result[name] = val;
41
+ }
42
+ }
43
+ return result;
44
+ }
45
+ export function parseCookieString(cookieStr) {
46
+ const trimmed = cookieStr.trim();
47
+ if (!trimmed) {
48
+ return {};
49
+ }
50
+ if (!trimmed.includes("=")) {
51
+ return { "__Http-typst-session": trimmed };
52
+ }
53
+ const map = {};
54
+ for (const part of trimmed.split(";")) {
55
+ const p = part.trim();
56
+ if (!p) {
57
+ continue;
58
+ }
59
+ const eq = p.indexOf("=");
60
+ if (eq <= 0) {
61
+ continue;
62
+ }
63
+ const k = p.slice(0, eq).trim();
64
+ const v = p.slice(eq + 1).trim();
65
+ if (k && v) {
66
+ map[k] = v;
67
+ }
68
+ }
69
+ return map;
70
+ }
71
+ export class TypstAuthManager {
72
+ appUrl;
73
+ apiUrl;
74
+ sessionFilePath;
75
+ fetchImpl;
76
+ browserFallbackFn;
77
+ cookies = {};
78
+ authMethod = null;
79
+ email;
80
+ constructor(options = {}) {
81
+ const rawAppUrl = options.appUrl ?? process.env.TYPST_APP_URL ?? "https://typst.app";
82
+ const rawApiUrl = options.apiUrl ?? process.env.TYPST_API_URL ?? "https://api.typst.app";
83
+ this.appUrl = rawAppUrl.replace(/\/+$/, "");
84
+ this.apiUrl = rawApiUrl.replace(/\/+$/, "");
85
+ this.sessionFilePath = resolveSessionFilePath(options.sessionFilePath);
86
+ this.fetchImpl = options.fetchImpl ?? fetch;
87
+ this.browserFallbackFn = options.browserFallbackFn;
88
+ this.loadStoredSession();
89
+ if (Object.keys(this.cookies).length === 0 &&
90
+ process.env.TYPST_SESSION_COOKIE) {
91
+ this.setSessionCookie(process.env.TYPST_SESSION_COOKIE);
92
+ }
93
+ }
94
+ loadStoredSession() {
95
+ if (!this.sessionFilePath || !existsSync(this.sessionFilePath)) {
96
+ return;
97
+ }
98
+ try {
99
+ const raw = JSON.parse(readFileSync(this.sessionFilePath, "utf8"));
100
+ if (raw && typeof raw.cookies === "object" && raw.cookies) {
101
+ this.cookies = { ...raw.cookies };
102
+ this.authMethod = raw.authMethod ?? "cookie";
103
+ this.email = raw.email;
104
+ }
105
+ }
106
+ catch {
107
+ // Ignore corrupted session file
108
+ }
109
+ }
110
+ saveStoredSession() {
111
+ if (!this.sessionFilePath) {
112
+ return;
113
+ }
114
+ try {
115
+ mkdirSync(dirname(this.sessionFilePath), { recursive: true });
116
+ const payload = {
117
+ cookies: this.cookies,
118
+ email: this.email,
119
+ authMethod: this.authMethod ?? "cookie",
120
+ updatedAt: new Date().toISOString(),
121
+ };
122
+ writeFileSync(this.sessionFilePath, JSON.stringify(payload, null, 2), "utf8");
123
+ }
124
+ catch {
125
+ // Non-fatal if filesystem is read-only
126
+ }
127
+ }
128
+ setSessionCookie(cookieInput, email) {
129
+ const parsed = parseCookieString(cookieInput);
130
+ this.cookies = { ...this.cookies, ...parsed };
131
+ this.authMethod = "cookie";
132
+ if (email) {
133
+ this.email = email;
134
+ }
135
+ this.saveStoredSession();
136
+ }
137
+ getCookieHeader() {
138
+ return Object.entries(this.cookies)
139
+ .map(([k, v]) => `${k}=${v}`)
140
+ .join("; ");
141
+ }
142
+ hasSession() {
143
+ return Boolean(this.cookies["__Http-typst-session"] ||
144
+ this.cookies["typst-session"] ||
145
+ this.cookies["session"]);
146
+ }
147
+ getStatus() {
148
+ return {
149
+ authenticated: this.hasSession(),
150
+ authMethod: this.authMethod,
151
+ email: this.email,
152
+ cookieNames: Object.keys(this.cookies),
153
+ };
154
+ }
155
+ async login(options = {}) {
156
+ const sessionCookie = options.sessionCookie ?? process.env.TYPST_SESSION_COOKIE;
157
+ const email = options.email ?? process.env.TYPST_EMAIL;
158
+ const password = options.password ?? process.env.TYPST_PASSWORD;
159
+ if (sessionCookie && (!email || !password)) {
160
+ this.setSessionCookie(sessionCookie, email);
161
+ return {
162
+ authenticated: this.hasSession(),
163
+ authMethod: "cookie",
164
+ email: this.email,
165
+ cookieNames: Object.keys(this.cookies),
166
+ };
167
+ }
168
+ if (!email || !password) {
169
+ if (sessionCookie) {
170
+ this.setSessionCookie(sessionCookie, email);
171
+ return {
172
+ authenticated: this.hasSession(),
173
+ authMethod: "cookie",
174
+ email: this.email,
175
+ cookieNames: Object.keys(this.cookies),
176
+ };
177
+ }
178
+ throw new Error("Missing credentials: provide email & password (or TYPST_EMAIL / TYPST_PASSWORD), or sessionCookie (TYPST_SESSION_COOKIE).");
179
+ }
180
+ // Step 1: Initialize pre-auth session cookie via GET /v1/to-signin
181
+ const initRes = await this.fetchImpl(`${this.apiUrl}/v1/to-signin`, {
182
+ method: "GET",
183
+ redirect: "manual",
184
+ headers: {
185
+ "User-Agent": DEFAULT_USER_AGENT,
186
+ },
187
+ });
188
+ const preAuthCookies = parseSetCookieHeaders(initRes.headers);
189
+ const mergedCookies = {
190
+ ...this.cookies,
191
+ ...preAuthCookies,
192
+ };
193
+ const cookieHeader = Object.entries(mergedCookies)
194
+ .map(([k, v]) => `${k}=${v}`)
195
+ .join("; ");
196
+ // Step 2: Submit credentials to POST /v1/auth/signin
197
+ const form = new URLSearchParams();
198
+ form.set("email", email);
199
+ form.set("password", password);
200
+ form.set("redirect", "");
201
+ form.set("grant-id", "");
202
+ form.set("captcha-trap", ""); // Honeypot field must remain empty
203
+ const signinRes = await this.fetchImpl(`${this.apiUrl}/v1/auth/signin`, {
204
+ method: "POST",
205
+ redirect: "manual",
206
+ headers: {
207
+ "Content-Type": "application/x-www-form-urlencoded",
208
+ Cookie: cookieHeader,
209
+ Origin: this.appUrl,
210
+ Referer: `${this.appUrl}/signin/`,
211
+ "User-Agent": DEFAULT_USER_AGENT,
212
+ },
213
+ body: form.toString(),
214
+ });
215
+ const postAuthCookies = parseSetCookieHeaders(signinRes.headers);
216
+ Object.assign(mergedCookies, postAuthCookies);
217
+ const location = signinRes.headers.get("location") ?? "";
218
+ const isChallengeOrBlocked = signinRes.status === 403 ||
219
+ signinRes.status === 429 ||
220
+ location.includes("captcha") ||
221
+ location.includes("challenge");
222
+ const isInvalidCredentials = signinRes.status === 401 ||
223
+ location.includes("error=") ||
224
+ location.includes("invalid");
225
+ if (!isChallengeOrBlocked &&
226
+ !isInvalidCredentials &&
227
+ (signinRes.status === 200 ||
228
+ signinRes.status === 302 ||
229
+ signinRes.status === 303) &&
230
+ (mergedCookies["__Http-typst-session"] || mergedCookies["typst-session"])) {
231
+ this.cookies = mergedCookies;
232
+ this.authMethod = "api-password";
233
+ this.email = email;
234
+ this.saveStoredSession();
235
+ return {
236
+ authenticated: true,
237
+ authMethod: "api-password",
238
+ email,
239
+ cookieNames: Object.keys(this.cookies),
240
+ };
241
+ }
242
+ // Step 3: Automatic fallback to cookie or browser session if Turnstile/CAPTCHA challenge occurs
243
+ if (options.useBrowserFallback !== false &&
244
+ (isChallengeOrBlocked || !this.hasSession())) {
245
+ if (sessionCookie) {
246
+ this.setSessionCookie(sessionCookie, email);
247
+ return {
248
+ authenticated: this.hasSession(),
249
+ authMethod: "cookie",
250
+ email,
251
+ cookieNames: Object.keys(this.cookies),
252
+ };
253
+ }
254
+ const fallbackRunner = this.browserFallbackFn ?? defaultPlaywrightBrowserFallback;
255
+ if (fallbackRunner) {
256
+ const fallbackCookies = await fallbackRunner(this.appUrl, this.apiUrl, email, password);
257
+ this.cookies = { ...mergedCookies, ...fallbackCookies };
258
+ this.authMethod = "browser-fallback";
259
+ this.email = email;
260
+ this.saveStoredSession();
261
+ return {
262
+ authenticated: this.hasSession(),
263
+ authMethod: "browser-fallback",
264
+ email,
265
+ cookieNames: Object.keys(this.cookies),
266
+ };
267
+ }
268
+ }
269
+ let redirectDetail = "";
270
+ if (location) {
271
+ redirectDetail = `, redirect: ${location}`;
272
+ }
273
+ throw new Error(`Sign-in failed (HTTP ${signinRes.status}${redirectDetail}). Verify TYPST_EMAIL/TYPST_PASSWORD or supply TYPST_SESSION_COOKIE.`);
274
+ }
275
+ }
276
+ export const defaultPlaywrightBrowserFallback = async (appUrl, _apiUrl, email, password) => {
277
+ const { spawnSync } = await import("node:child_process");
278
+ const pyScript = `
279
+ import json, os
280
+ from playwright.sync_api import sync_playwright
281
+
282
+ with sync_playwright() as p:
283
+ browser = p.chromium.launch(channel="msedge", headless=True)
284
+ ctx = browser.new_context()
285
+ page = ctx.new_page()
286
+ page.goto(os.environ["TYPST_FB_URL"] + "/signin/", wait_until="networkidle")
287
+ page.fill("input[name='email']", os.environ["TYPST_FB_EMAIL"])
288
+ page.fill("input[name='password']", os.environ["TYPST_FB_PASS"])
289
+ page.click("input[type='submit']")
290
+ page.wait_for_load_state("networkidle")
291
+ cookies = {c["name"]: c["value"] for c in ctx.cookies()}
292
+ print(json.dumps(cookies))
293
+ browser.close()
294
+ `;
295
+ const proc = spawnSync("python", ["-c", pyScript], {
296
+ env: {
297
+ ...process.env,
298
+ TYPST_FB_URL: appUrl,
299
+ TYPST_FB_EMAIL: email,
300
+ TYPST_FB_PASS: password,
301
+ },
302
+ encoding: "utf8",
303
+ timeout: 30000,
304
+ });
305
+ if (proc.status !== 0 || !proc.stdout.trim()) {
306
+ throw new Error(`Playwright browser fallback failed: ${proc.stderr || "no output"}`);
307
+ }
308
+ const lines = proc.stdout.trim().split(/\r\n|\n/);
309
+ return JSON.parse(lines[lines.length - 1]);
310
+ };