diff --git a/packages/mcp-server/drawio-files.txt b/packages/mcp-server/drawio-files.txt
new file mode 100644
index 00000000..5afded21
--- /dev/null
+++ b/packages/mcp-server/drawio-files.txt
@@ -0,0 +1,34 @@
+# Files copied from draw.war into dist/drawio by scripts/fetch-drawio.mjs
+# (the preview page embeds this copy from its own origin). `*` matches within
+# a path segment, `**` across segments.
+#
+# Always included: the language files, the image icon libraries (loaded one
+# icon at a time, as diagrams use them), the stylesheets, and the licenses of
+# the icon sets.
+img/LICENSE
+img/lib/**
+mxgraph/css/**
+resources/dia*.txt
+shapes/LICENSE
+stencils/LICENSE
+styles/**
+
+# Requested by the editor; rewritten by scripts/check-drawio-files.mjs --update
+images/droptarget.png
+images/github-logo.svg
+images/logo-flat-small.png
+images/sidebar-general.png
+images/spin.gif
+img/clipart/Gear_128x128.png
+index.html
+js/PostConfig.js
+js/PreConfig.js
+js/app.min.js
+js/bootstrap.js
+js/extensions.min.js
+js/main.js
+js/shapes-14-6-5.min.js
+js/stencils.min.js
+math4/es5/drawio-mathjax.min.js
+mxgraph/images/maximize.gif
+shortcuts.svg
diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json
index 9349eb6f..7919c195 100644
--- a/packages/mcp-server/package.json
+++ b/packages/mcp-server/package.json
@@ -8,7 +8,7 @@
"next-ai-drawio-mcp": "./dist/index.js"
},
"scripts": {
- "build": "tsc && node scripts/copy-assets.mjs",
+ "build": "tsc && node scripts/copy-assets.mjs && node scripts/fetch-drawio.mjs",
"check-package": "node scripts/check-package.mjs",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
diff --git a/packages/mcp-server/scripts/check-drawio-files.mjs b/packages/mcp-server/scripts/check-drawio-files.mjs
new file mode 100644
index 00000000..480692d0
--- /dev/null
+++ b/packages/mcp-server/scripts/check-drawio-files.mjs
@@ -0,0 +1,474 @@
+#!/usr/bin/env node
+import {
+ existsSync,
+ readdirSync,
+ readFileSync,
+ statSync,
+ writeFileSync,
+} from "node:fs"
+/**
+ * Guard for drawio-files.txt: opens the embedded editor from a FULL draw.io
+ * copy in headless Chromium, uses it the way the preview page does (every
+ * theme the user can switch to, diagrams with shapes from the icon libraries,
+ * the format panel, the shape sidebar, the menus, the page tabs, the export
+ * and edit dialogs, exports) and records every file the editor requests.
+ *
+ * node scripts/check-drawio-files.mjs [--drawio
] [--update]
+ *
+ * The full copy is --drawio, else the web app's public/drawio. Without
+ * --update it fails when the editor requested a file the list does not
+ * have; with --update it rewrites the requested section of the list.
+ * Needs Playwright from the repository root (npm ci at the root).
+ */
+import http from "node:http"
+import path from "node:path"
+import { fileURLToPath, pathToFileURL } from "node:url"
+import {
+ formatFileList,
+ parseFileList,
+ readFileList,
+} from "./drawio-file-list.mjs"
+
+const PKG = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
+const LIST = path.join(PKG, "drawio-files.txt")
+const args = process.argv.slice(2)
+const update = args.includes("--update")
+const drawioDir = path.resolve(
+ args.includes("--drawio")
+ ? args[args.indexOf("--drawio") + 1]
+ : path.join(PKG, "../../public/drawio"),
+)
+if (!existsSync(path.join(drawioDir, "index.html"))) {
+ console.error(
+ `No draw.io copy at ${drawioDir}. Run "npm run dev" once at the repository root (it downloads one into public/drawio), or pass --drawio .`,
+ )
+ process.exit(2)
+}
+const playwrightEntry = path.join(
+ PKG,
+ "../../node_modules/playwright/index.mjs",
+)
+if (!existsSync(playwrightEntry)) {
+ console.error("Playwright is missing: run npm ci at the repository root.")
+ process.exit(2)
+}
+const { chromium } = await import(pathToFileURL(playwrightEntry).href)
+
+// The themes a user can switch to under Extras > Theme (themes=1)
+const THEMES = ["kennedy", "min", "atlas", "dark", "sketch", "simple"]
+
+const MIME = {
+ ".html": "text/html",
+ ".js": "text/javascript",
+ ".css": "text/css",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".gif": "image/gif",
+ ".json": "application/json",
+ ".xml": "text/xml",
+ ".txt": "text/plain",
+ ".woff": "font/woff",
+ ".woff2": "font/woff2",
+ ".ttf": "font/ttf",
+ ".ico": "image/x-icon",
+}
+
+// The page that embeds the editor, with the preview page's iframe query
+const parentPage = (ui) => `
+
+`
+
+// Every path the editor asked for, in order; and the ones the full copy lacks
+const requested = []
+const notFound = new Set()
+const server = http.createServer((req, res) => {
+ const url = new URL(req.url, "http://localhost")
+ if (url.pathname === "/") {
+ res.writeHead(200, { "Content-Type": "text/html" })
+ res.end(parentPage(url.searchParams.get("ui") || "kennedy"))
+ return
+ }
+ const rel = path.posix.normalize(
+ decodeURIComponent(url.pathname.replace(/^\/drawio\//, "")),
+ )
+ const file = path.join(drawioDir, rel)
+ let stat
+ try {
+ stat = statSync(file)
+ } catch {
+ notFound.add(rel)
+ res.writeHead(404)
+ res.end()
+ return
+ }
+ if (!requested.includes(rel)) requested.push(rel)
+ res.writeHead(200, {
+ "Content-Type": MIME[path.extname(file)] || "application/octet-stream",
+ "Content-Length": stat.size,
+ })
+ res.end(readFileSync(file))
+})
+await new Promise((r) => server.listen(0, "127.0.0.1", r))
+const port = server.address().port
+
+const mb = (n) => (n / 1048576).toFixed(2)
+let seen = 0
+function report(step) {
+ const added = requested.slice(seen)
+ seen = requested.length
+ if (added.length === 0) return
+ const bytes = added.reduce(
+ (n, f) => n + statSync(path.join(drawioDir, f)).size,
+ 0,
+ )
+ const names =
+ added.slice(0, 5).join(", ") + (added.length > 5 ? ", ..." : "")
+ console.log(
+ ` ${step}: +${added.length} files, +${mb(bytes)} MB [${names}]`,
+ )
+}
+
+// Diagrams: a plain shape, one shape from each icon library, and the styles
+// that make draw.io load code on demand (hand-drawn look, math)
+const cell = (id, style, x) =>
+ ``
+const doc = (cells, attrs = "") =>
+ `${cells}`
+const LIBS = {
+ plain: "rounded=1;whiteSpace=wrap;html=1;",
+ aws4: "shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.a1_instance;fillColor=#ED7100;strokeColor=#ffffff;",
+ gcp2: "shape=mxgraph.gcp2.a7_power;fillColor=#4285F4;strokeColor=none;",
+ kubernetes:
+ "shape=mxgraph.kubernetes.icon;prIcon=api;fillColor=#326CE5;strokeColor=none;",
+ cisco19:
+ "shape=mxgraph.cisco19.rect;prIcon=aci;fillColor=#00bceb;strokeColor=none;",
+ flowchart:
+ "shape=mxgraph.flowchart.annotation_1;fillColor=#d5e8d4;strokeColor=#82b366;",
+ webicons:
+ "shape=mxgraph.webicons.adfty;fillColor=#3b5998;strokeColor=none;",
+ bpmn: "shape=mxgraph.bpmn.shape;symbol=message;outline=throwing;",
+ networks:
+ "shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;",
+ azure2_image:
+ "image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;",
+ sketch: "rounded=1;whiteSpace=wrap;html=1;sketch=1;",
+}
+const MATH_DOC = doc(
+ cell("math", "rounded=1;whiteSpace=wrap;html=1;", 40).replace(
+ 'value="math"',
+ 'value="$$x^2$$"',
+ ),
+ 'math="1"',
+)
+
+// Dialogs and toggles, by draw.io action name (Actions.js); each is closed
+// again right after it opened. Left out on purpose, to keep the package
+// small: Insert > Template (5.6 MB of templates and previews) and the
+// Insert > Advanced tools that load code when used (Mermaid, PlantUML).
+const ACTIONS = [
+ "editData",
+ "editDiagram",
+ "editStyle",
+ "editGeometry",
+ "editTooltip",
+ "editLink",
+ "insertImage",
+ "exportPng",
+ "exportSvg",
+ "exportJpg",
+ "exportWebp",
+ "exportPdf",
+ "exportXml",
+ "exportHtml",
+ "exportVsdx",
+ "embedHtml",
+ "embedImage",
+ "embedSvg",
+ "embedIframe",
+ "about",
+ "pageSetup",
+ "print",
+ "findReplace",
+ "tags",
+ "layers",
+ "outline",
+ "shapes",
+ "scratchpad",
+ "plugins",
+ "properties",
+ "fromText",
+ "csv",
+ "runLayout",
+ "insertFreehand",
+ "comments",
+ "customZoom",
+ "renamePage",
+ "format",
+ "format",
+ "toggleShapes",
+ "toggleShapes",
+ "darkMode",
+ "lightMode",
+ "autoMode",
+ "highContrast",
+ "highContrast",
+ "mathematicalTypesetting",
+ "mathematicalTypesetting",
+ "keyboardShortcuts",
+]
+// Enough for the other themes, whose dialogs are the same code
+const LIGHT_ACTIONS = ["editData", "exportPng", "shapes", "format", "format"]
+
+const wait = (page, ms) => page.waitForTimeout(ms)
+
+/**
+ * Open the editor in one theme and use it. The default theme gets the full
+ * tour; the others the parts that differ by theme (chrome, menus, panels).
+ */
+async function drive(browser, ui, full) {
+ console.log(`theme ${ui}${full ? "" : " (light)"}`)
+ const page = await browser.newPage({
+ viewport: { width: 1300, height: 900 },
+ })
+ page.on("popup", (p) => p.close().catch(() => {}))
+ await page.goto(`http://localhost:${port}/?ui=${ui}`)
+ await page.waitForFunction(() => window.ready === true, null, {
+ timeout: 60000,
+ })
+ await wait(page, 2000)
+ report("editor ready")
+
+ const load = (xml) =>
+ page.evaluate(
+ (x) => window.send({ action: "load", xml: x, autosave: 1 }),
+ xml,
+ )
+ let x = 40
+ for (const [name, style] of Object.entries(LIBS)) {
+ await load(doc(cell(name, style, (x += 100))))
+ await wait(page, full ? 1500 : 500)
+ report(`load ${name}`)
+ if (!full) break
+ }
+ if (full) {
+ await load(MATH_DOC)
+ await wait(page, 2500)
+ report("load math")
+ }
+
+ // The editor instance: in embed mode draw.io calls a plugin callback
+ // registered after startup right away
+ const frame = page.frames().find((f) => f.url().includes("/drawio/"))
+ await frame.evaluate(() => {
+ window.Draw.loadPlugin((u) => {
+ window.__ui = u
+ })
+ window.__ui.editor.graph.selectAll()
+ })
+ const closeAll = async () => {
+ await frame.evaluate(() => {
+ const ui = window.__ui
+ while (ui.dialogs && ui.dialogs.length > 0) ui.hideDialog()
+ ui.hideCurrentMenu?.()
+ })
+ await page.keyboard.press("Escape")
+ await wait(page, 100)
+ }
+
+ let opened = 0
+ const actions = full ? ACTIONS : LIGHT_ACTIONS
+ for (const name of actions) {
+ const result = await frame.evaluate((n) => {
+ const ui = window.__ui
+ const action = ui.actions.get(n)
+ if (!action) return "missing"
+ try {
+ action.funct()
+ } catch (e) {
+ return `failed: ${e.message}`
+ }
+ return ui.dialog || ui.dialogs?.length ? "dialog" : "ok"
+ }, name)
+ if (result === "dialog") opened++
+ if (result !== "ok" && result !== "dialog") {
+ console.log(` action ${name}: ${result}`)
+ }
+ await wait(page, 400)
+ report(`action ${name}`)
+ await closeAll()
+ }
+ console.log(` ${opened} of ${actions.length} actions opened a dialog`)
+
+ // Menus: the visible menubar items, the toolbar buttons that open a menu
+ // in the other themes, and every menu rendered with its submenus
+ const menubarItems = frame.locator(".geMenubar .geItem:visible")
+ const menubarCount = await menubarItems.count()
+ for (let i = 0; i < menubarCount; i++) {
+ await menubarItems
+ .nth(i)
+ .click({ timeout: 2000 })
+ .catch(() => {})
+ await wait(page, 300)
+ await closeAll()
+ }
+ let toolbarCount = 0
+ for (const title of [
+ "Diagram",
+ "Insert",
+ "Shapes",
+ "Pages",
+ "View",
+ "Table",
+ "Layout",
+ ]) {
+ const button = frame.locator(`a[title="${title}"]:visible`).first()
+ if ((await button.count()) === 0) continue
+ toolbarCount++
+ await button.click({ timeout: 2000 }).catch(() => {})
+ await wait(page, 300)
+ await closeAll()
+ }
+ const failed = await frame.evaluate(() => {
+ const ui = window.__ui
+ const failed = []
+ for (const name of Object.keys(ui.menus.menus)) {
+ const menu = new window.mxPopupMenu()
+ menu.init()
+ document.body.appendChild(menu.div)
+ try {
+ ui.menus.get(name).funct(menu, null)
+ } catch {
+ failed.push(name)
+ }
+ menu.destroy()
+ }
+ return failed
+ })
+ await wait(page, 500)
+ console.log(
+ ` menus: ${menubarCount} menubar items, ${toolbarCount} toolbar menus clicked` +
+ (failed.length ? `; not rendered: ${failed.join(", ")}` : ""),
+ )
+ report("menus")
+
+ // Format panel tabs, with a shape selected
+ await frame.evaluate(() => window.__ui.editor.graph.selectAll())
+ for (const tab of ["Style", "Text", "Arrange", "Diagram"]) {
+ const el = frame
+ .locator(`.geFormatContainer div:text-is("${tab}")`)
+ .first()
+ if ((await el.count()) > 0)
+ await el.click({ timeout: 2000 }).catch(() => {})
+ await wait(page, 200)
+ }
+ report("format panel tabs")
+
+ // Shape sidebar: open every palette, then search
+ await frame.evaluate(() => {
+ const ui = window.__ui
+ for (const title of document.querySelectorAll(
+ ".geSidebarContainer .geTitle",
+ )) {
+ title.click()
+ }
+ ui.sidebar.searchShapes?.("server")
+ })
+ await wait(page, full ? 2500 : 1000)
+ report("sidebar palettes and search")
+
+ // Pages: insert, switch, duplicate, remove, and the tab bar
+ for (const name of [
+ "insertPage",
+ "nextPage",
+ "previousPage",
+ "duplicatePage",
+ "removePage",
+ ]) {
+ await frame.evaluate((n) => window.__ui.actions.get(n)?.funct(), name)
+ await wait(page, 150)
+ await closeAll()
+ }
+ for (const title of ["Insert Page", "Pages"]) {
+ const el = frame.locator(`a[title="${title}"]:visible`).first()
+ if ((await el.count()) > 0)
+ await el.click({ timeout: 2000 }).catch(() => {})
+ await wait(page, 300)
+ await closeAll()
+ }
+ report("page tabs")
+
+ if (full) {
+ for (const format of ["png", "svg", "xmlsvg", "xml"]) {
+ await page.evaluate(
+ (f) => window.send({ action: "export", format: f, scale: 2 }),
+ format,
+ )
+ await wait(page, 1500)
+ report(`export ${format}`)
+ }
+ const exports = await page.evaluate(() => window.exports)
+ if (exports < 4) console.log(` only ${exports} of 4 exports answered`)
+ }
+ await page.close()
+}
+
+const browser = await chromium.launch()
+try {
+ for (const ui of THEMES) await drive(browser, ui, ui === THEMES[0])
+} finally {
+ await browser.close()
+ server.close()
+}
+
+if (notFound.size > 0) {
+ console.log(
+ `\nNot in the full copy either (ignored): ${[...notFound].join(", ")}`,
+ )
+}
+
+// Compare with the list
+const list = readFileList(LIST)
+const missing = requested.filter((f) => !list.matches(f)).sort()
+
+function* walk(dir) {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = path.join(dir, entry.name)
+ if (entry.isDirectory()) yield* walk(full)
+ else yield path.relative(drawioDir, full).split(path.sep).join("/")
+ }
+}
+const summarize = (fileList) => {
+ let count = 0
+ let bytes = 0
+ for (const rel of walk(drawioDir)) {
+ if (!fileList.matches(rel)) continue
+ count++
+ bytes += statSync(path.join(drawioDir, rel)).size
+ }
+ return `${count} files, ${mb(bytes)} MB unpacked`
+}
+
+if (update) {
+ // Only the head decides what is already covered by "always include"
+ const always = parseFileList(list.head)
+ const kept = requested.filter((f) => !always.matches(f))
+ writeFileSync(LIST, formatFileList(list.head, kept))
+ console.log(`\nWrote ${LIST}: ${summarize(readFileList(LIST))}`)
+} else if (missing.length > 0) {
+ console.error(
+ `\nThe editor requested ${missing.length} files that drawio-files.txt does not list:\n ${missing.join("\n ")}\nRun with --update to add them.`,
+ )
+ process.exit(1)
+} else {
+ console.log(`\ndrawio-files.txt is complete: ${summarize(list)}`)
+}
diff --git a/packages/mcp-server/scripts/drawio-file-list.mjs b/packages/mcp-server/scripts/drawio-file-list.mjs
new file mode 100644
index 00000000..a4483817
--- /dev/null
+++ b/packages/mcp-server/scripts/drawio-file-list.mjs
@@ -0,0 +1,78 @@
+/**
+ * drawio-files.txt: the files copied from draw.war into dist/drawio.
+ *
+ * One path per line; `*` matches within a path segment and `**` across
+ * segments; `#` starts a comment. Everything above the marker line is kept
+ * as it is ("always include"); the paths below it are what the embedded
+ * editor requested in a guard run, rewritten by check-drawio-files.mjs
+ * --update.
+ */
+import fs from "node:fs"
+
+export const MARKER =
+ "# Requested by the editor; rewritten by scripts/check-drawio-files.mjs --update"
+
+function toRegExp(pattern) {
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&")
+ const source = escaped
+ .replace(/\*\*/g, "\0")
+ .replace(/\*/g, "[^/]*")
+ .replace(/\0/g, ".*")
+ return new RegExp(`^${source}$`)
+}
+
+const entries = (text) =>
+ text
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith("#"))
+
+export function parseFileList(text) {
+ const at = text.indexOf(MARKER)
+ if (at < 0) throw new Error(`drawio-files.txt has no "${MARKER}" line`)
+ const head = text.slice(0, at + MARKER.length)
+ const always = entries(head)
+ const requested = entries(text.slice(at + MARKER.length))
+
+ const exact = new Set()
+ const patterns = []
+ for (const entry of [...always, ...requested]) {
+ if (entry.includes("*")) {
+ patterns.push({ text: entry, re: toRegExp(entry), hit: false })
+ } else {
+ exact.add(entry)
+ }
+ }
+
+ return {
+ head,
+ always,
+ requested,
+ /** Whether the list names this file (remembers which patterns hit) */
+ matches(name) {
+ if (exact.has(name)) return true
+ let found = false
+ for (const p of patterns) {
+ if (p.re.test(name)) p.hit = found = true
+ }
+ return found
+ },
+ /** Entries that matched none of `seen` after matches() ran on them */
+ unmatched(seen) {
+ return [
+ ...[...exact].filter((name) => !seen.has(name)),
+ ...patterns.filter((p) => !p.hit).map((p) => p.text),
+ ]
+ },
+ }
+}
+
+export function readFileList(file) {
+ return parseFileList(fs.readFileSync(file, "utf8"))
+}
+
+/** The list text with a new requested section (sorted, unique) */
+export function formatFileList(head, requested) {
+ const body = [...new Set(requested)].sort().join("\n")
+ return `${head}\n${body}\n`
+}
diff --git a/packages/mcp-server/scripts/fetch-drawio.mjs b/packages/mcp-server/scripts/fetch-drawio.mjs
new file mode 100644
index 00000000..7017808a
--- /dev/null
+++ b/packages/mcp-server/scripts/fetch-drawio.mjs
@@ -0,0 +1,132 @@
+#!/usr/bin/env node
+/**
+ * Build step: put a trimmed copy of draw.io into dist/drawio, so the preview
+ * page can embed the editor from its own origin (same-origin editor access,
+ * and no internet needed at runtime).
+ *
+ * Downloads the pinned draw.war (see src/drawio-version.json) into a cache
+ * directory (DRAWIO_WAR_CACHE, default /next-ai-drawio), checks its
+ * SHA-256, and extracts only the files listed in drawio-files.txt. Set
+ * DRAWIO_WAR= to use a local war without downloading. The list is kept
+ * up to date by scripts/check-drawio-files.mjs.
+ */
+import fs from "node:fs"
+import os from "node:os"
+import path from "node:path"
+import { fileURLToPath } from "node:url"
+import {
+ readDrawioVersion,
+ readZipEntries,
+ sha256,
+} from "../../../scripts/drawio-zip.mjs"
+import { readFileList } from "./drawio-file-list.mjs"
+
+const PKG = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
+const DEST = path.join(PKG, "dist/drawio")
+const { version, sha256: expectedSha, downloadUrl } = readDrawioVersion()
+
+function log(message) {
+ console.log(`[fetch-drawio] ${message}`)
+}
+
+/** The draw.war as a buffer, from DRAWIO_WAR, the cache, or GitHub */
+async function readWar() {
+ const verified = (buf, source) => {
+ const actual = sha256(buf)
+ if (actual !== expectedSha) {
+ throw new Error(
+ `${source} is not draw.io ${version} (SHA-256 ${actual})`,
+ )
+ }
+ return buf
+ }
+
+ if (process.env.DRAWIO_WAR) {
+ log(`Using ${process.env.DRAWIO_WAR}`)
+ return verified(fs.readFileSync(process.env.DRAWIO_WAR), "DRAWIO_WAR")
+ }
+
+ const cacheDir =
+ process.env.DRAWIO_WAR_CACHE || path.join(os.tmpdir(), "next-ai-drawio")
+ const cached = path.join(cacheDir, `draw-${version}.war`)
+ if (fs.existsSync(cached)) {
+ try {
+ return verified(fs.readFileSync(cached), cached)
+ } catch (error) {
+ log(`${error.message}; downloading again`)
+ fs.rmSync(cached, { force: true })
+ }
+ }
+
+ log(`Downloading draw.io ${version} ...`)
+ const res = await fetch(downloadUrl)
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${downloadUrl}`)
+ const buf = verified(Buffer.from(await res.arrayBuffer()), downloadUrl)
+ fs.mkdirSync(cacheDir, { recursive: true })
+ const tmp = `${cached}.tmp-${process.pid}`
+ fs.writeFileSync(tmp, buf)
+ fs.renameSync(tmp, cached)
+ log(`Cached at ${cached}`)
+ return buf
+}
+
+/** Apache-2.0 text with a notice naming draw.io, for dist/drawio/LICENSE */
+function licenseText(warLicense) {
+ const notice =
+ `The files in this directory are an unmodified subset of draw.io ${version}\n` +
+ "(https://github.com/jgraph/drawio), Copyright (c) JGraph Ltd, redistributed\n" +
+ "under the Apache License, Version 2.0 reproduced below. The icon sets under\n" +
+ "img/lib and the stencils built into the scripts have additional terms, see\n" +
+ "img/LICENSE, shapes/LICENSE and stencils/LICENSE.\n\n"
+ if (warLicense) return notice + warLicense
+ // The standard terms, without this project's own copyright notice
+ const apache = fs.readFileSync(path.join(PKG, "../../LICENSE"), "utf8")
+ const end = "END OF TERMS AND CONDITIONS"
+ return notice + apache.slice(0, apache.indexOf(end) + end.length) + "\n"
+}
+
+async function main() {
+ const list = readFileList(path.join(PKG, "drawio-files.txt"))
+ const buf = await readWar()
+
+ const tmp = `${DEST}.tmp-${process.pid}`
+ fs.rmSync(tmp, { recursive: true, force: true })
+ const written = new Set()
+ let bytes = 0
+ let warLicense = null
+ for (const { name, data } of readZipEntries(buf)) {
+ // Server-side parts of the war; never shipped, whatever the list says
+ if (/^(WEB-INF|META-INF)\//.test(name)) continue
+ if (name === "LICENSE") warLicense = data.toString("utf8")
+ if (!list.matches(name)) continue
+ const target = path.join(tmp, name)
+ if (!target.startsWith(tmp + path.sep)) {
+ throw new Error(`Unsafe path in archive: ${name}`)
+ }
+ fs.mkdirSync(path.dirname(target), { recursive: true })
+ fs.writeFileSync(target, data)
+ written.add(name)
+ bytes += data.length
+ }
+
+ const missing = list.unmatched(written)
+ if (missing.length > 0) {
+ throw new Error(
+ `drawio-files.txt names files that draw.io ${version} does not have:\n ${missing.join("\n ")}\n` +
+ "Run scripts/check-drawio-files.mjs --update to refresh the list.",
+ )
+ }
+
+ fs.writeFileSync(path.join(tmp, "LICENSE"), licenseText(warLicense))
+ fs.writeFileSync(path.join(tmp, ".version"), `${version}\n`)
+ fs.rmSync(DEST, { recursive: true, force: true })
+ fs.renameSync(tmp, DEST)
+ log(
+ `Installed ${written.size} files (${(bytes / 1048576).toFixed(1)} MB) into dist/drawio`,
+ )
+}
+
+main().catch((error) => {
+ console.error(`[fetch-drawio] ${error.stack || error}`)
+ process.exit(1)
+})