feat: ship prebuilt plugin bundle for remote dsh install

Remote github installs only get packages/dsh-plugin; without bundle the
host tried pnpm build and hung. Pack web+server into bundle/ and start
from it so other machines can run after dsh plugin add.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-12 10:45:08 +08:00
parent d95f033d1f
commit b23959400d
13 changed files with 28325 additions and 41 deletions

2
.gitignore vendored
View file

@ -11,3 +11,5 @@ uploads
apps/web/dist apps/web/dist
apps/server/dist apps/server/dist
packages/*/dist packages/*/dist
# keep packages/dsh-plugin/bundle (prebuilt for remote install)
packages/dsh-plugin/bundle/_testdata

View file

@ -57,7 +57,19 @@ async function callDshBridge(path: string, init?: RequestInit) {
} }
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const mindMapRoot = path.resolve(__dirname, "../../.."); /** Monorepo root, or plugin package root when launched from packaged bundle. */
export const mindMapRoot = process.env.MINDMAP_ROOT
? path.resolve(process.env.MINDMAP_ROOT)
: path.resolve(__dirname, "../../..");
export function resolveWebDist(root = mindMapRoot) {
if (process.env.MINDMAP_WEB_DIST) {
return path.resolve(process.env.MINDMAP_WEB_DIST);
}
const bundled = path.join(root, "bundle", "web");
if (fs.existsSync(path.join(bundled, "index.html"))) return bundled;
return path.join(root, "apps", "web", "dist");
}
export function loadDotEnv(root = mindMapRoot) { export function loadDotEnv(root = mindMapRoot) {
try { try {
@ -642,14 +654,13 @@ export function startMindMapServer(
} }
}); });
// Static UI: cwd is mind-map root when launched by the plugin. // Static UI: monorepo apps/web/dist, or packaged bundle/web via MINDMAP_WEB_DIST.
const webDistRel = "apps/web/dist"; const webDistAbs = resolveWebDist(root);
const webDistAbs = path.join(root, "apps/web/dist"); if (fs.existsSync(path.join(webDistAbs, "index.html"))) {
if (fs.existsSync(webDistAbs) || fs.existsSync(path.join(process.cwd(), webDistRel))) {
app.use( app.use(
"/*", "/*",
serveStatic({ serveStatic({
root: fs.existsSync(webDistAbs) ? webDistAbs : webDistRel, root: webDistAbs,
index: "index.html", index: "index.html",
}), }),
); );
@ -659,13 +670,10 @@ export function startMindMapServer(
if (p.startsWith("/api") || p === "/health" || p.startsWith("/ws") || p.startsWith("/uploads")) { if (p.startsWith("/api") || p === "/health" || p.startsWith("/ws") || p.startsWith("/uploads")) {
return c.notFound() return c.notFound()
} }
const indexPath = path.join( const indexPath = path.join(webDistAbs, "index.html");
fs.existsSync(webDistAbs) ? webDistAbs : path.join(process.cwd(), webDistRel),
"index.html",
);
if (!fs.existsSync(indexPath)) { if (!fs.existsSync(indexPath)) {
return c.text( return c.text(
"Mind Map UI not built. From mind-map repo run: pnpm --filter @mind-map/web build", "Mind Map UI not built. From mind-map repo run: pnpm --filter @mind-map/web build && pnpm pack:plugin",
503, 503,
); );
} }
@ -674,7 +682,7 @@ export function startMindMapServer(
} else { } else {
app.get("/", (c) => app.get("/", (c) =>
c.text( c.text(
"Mind Map UI missing (apps/web/dist). Run: pnpm --filter @mind-map/web build", `Mind Map UI missing (${webDistAbs}). Run: pnpm pack:plugin`,
503, 503,
), ),
); );

View file

@ -8,12 +8,14 @@
"dev:server": "pnpm --filter @mind-map/server dev", "dev:server": "pnpm --filter @mind-map/server dev",
"dev:web": "pnpm --filter @mind-map/web dev", "dev:web": "pnpm --filter @mind-map/web dev",
"build": "pnpm -r run build", "build": "pnpm -r run build",
"pack:plugin": "node packages/dsh-plugin/scripts/pack-bundle.mjs",
"test": "pnpm -r run test", "test": "pnpm -r run test",
"start": "pnpm --filter @mind-map/server start", "start": "pnpm --filter @mind-map/server start",
"typecheck": "pnpm -r run typecheck", "typecheck": "pnpm -r run typecheck",
"stress": "tsx scripts/stress-10k.mts" "stress": "tsx scripts/stress-10k.mts"
}, },
"devDependencies": { "devDependencies": {
"esbuild": "^0.25.12",
"tsx": "^4.19.3" "tsx": "^4.19.3"
}, },
"engines": { "engines": {

View file

@ -1,15 +1,30 @@
# dsh-mind-map 本地安装到 DSH web profile(开发用) # dsh-mind-map 安装
```powershell ## 远程安装(推荐)
$plugin = "d:\project\chatgpt\mind-map\packages\dsh-plugin"
# 用 dsh plugin add 链到本地包,或在 profile 里 link 仓库需包含预构建 `packages/dsh-plugin/bundle`(维护者执行 `pnpm pack:plugin` 后推送)。
dsh plugin --profile web add -w $plugin
```bash
dsh plugin --profile web add -w "github:hansjone/mind-map#path:packages/dsh-plugin"
``` ```
装好后: 装好后重启 `dsh web`,硬刷新浏览器。依赖:`dsh-better-sidebar`。
1. 重启 `dsh web`,浏览器硬刷新(Ctrl+Shift+R) 启动日志应出现:
2. 右侧栏「+」选 **思维导图**,或 设置 → Mind Map → **打开思维导图 Tab**
3. 拓扑占满右侧内容区;对话继续用中间 DSH 输入框
依赖:已安装 `dsh-better-sidebar`(提供 `ctx.betterSidebar.registerTab`)。 ```text
[dsh-mind-map] starting packaged bundle/server.mjs
```
若仍出现 `building web UI…`,说明装到的版本没有 `bundle/`,请更新插件或改用本地完整仓库。
## 本地开发(link 整个 monorepo)
```powershell
cd d:\project\chatgpt\mind-map
pnpm install
pnpm pack:plugin
dsh plugin --profile web add -w "d:\project\chatgpt\mind-map\packages\dsh-plugin"
```
开发改 UI 时可继续用 monorepo 源码启动(无 bundle 时会自动 `pnpm --filter @mind-map/web build`)。

View file

@ -0,0 +1 @@
Prebuilt assets for remote dsh plugin install. Regenerated by: node packages/dsh-plugin/scripts/pack-bundle.mjs

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mind Map</title>
<script type="module" crossorigin src="/assets/index-iTIYRavW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BK6FV7kG.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View file

@ -2,6 +2,10 @@
* Start / stop the mind-map Hono server as a child process. * Start / stop the mind-map Hono server as a child process.
* Lifetime is tied to the DSH host: when the plugin disposes / process exits, * Lifetime is tied to the DSH host: when the plugin disposes / process exits,
* the canvas server is killed (including process tree on Windows). * the canvas server is killed (including process tree on Windows).
*
* Two layouts:
* - Packaged (remote `dsh plugin add`): packages/dsh-plugin/bundle/{web,server.mjs}
* - Monorepo (local link): ../../.. mind-map root with apps/web + apps/server
*/ */
import { spawn, execSync } from 'node:child_process' import { spawn, execSync } from 'node:child_process'
@ -11,7 +15,26 @@ import { fileURLToPath } from 'node:url'
import { createRequire } from 'node:module' import { createRequire } from 'node:module'
const __dirname = dirname(fileURLToPath(import.meta.url)) const __dirname = dirname(fileURLToPath(import.meta.url))
export const mindMapRoot = join(__dirname, '../../..') /** dsh-mind-map package root (…/dsh-mind-map or …/packages/dsh-plugin) */
export const pluginRoot = join(__dirname, '..')
/** Monorepo root when linked from source; otherwise same as pluginRoot when packaged. */
export const mindMapRoot = (() => {
const mono = join(__dirname, '../../..')
if (existsSync(join(mono, 'apps', 'web', 'package.json'))) return mono
return pluginRoot
})()
function bundledServer() {
return join(pluginRoot, 'bundle', 'server.mjs')
}
function bundledWebIndex() {
return join(pluginRoot, 'bundle', 'web', 'index.html')
}
function isPackagedInstall() {
return existsSync(bundledServer()) && existsSync(bundledWebIndex())
}
function resolveTsxCliRel() { function resolveTsxCliRel() {
const candidates = [ const candidates = [
@ -33,6 +56,9 @@ function resolveTsxCliRel() {
} }
function ensureWebDist() { function ensureWebDist() {
// Remote/plugin install ships prebuilt assets — never run pnpm here.
if (isPackagedInstall()) return
const indexHtml = join(mindMapRoot, 'apps', 'web', 'dist', 'index.html') const indexHtml = join(mindMapRoot, 'apps', 'web', 'dist', 'index.html')
const watchSrc = [ const watchSrc = [
join(mindMapRoot, 'apps', 'web', 'src', 'TopologyCanvas.tsx'), join(mindMapRoot, 'apps', 'web', 'src', 'TopologyCanvas.tsx'),
@ -55,6 +81,16 @@ function ensureWebDist() {
} }
} }
if (!needsBuild) return if (!needsBuild) return
if (!existsSync(join(mindMapRoot, 'pnpm-workspace.yaml'))) {
throw new Error(
'mind-map web UI is missing and this is not a monorepo checkout. ' +
'Reinstall a release that includes packages/dsh-plugin/bundle ' +
'(github:hansjone/mind-map#path:packages/dsh-plugin after maintainer runs pnpm pack:plugin), ' +
'or clone the full repo and link locally.',
)
}
console.log('[dsh-mind-map] building web UI (apps/web/dist missing or stale)…') console.log('[dsh-mind-map] building web UI (apps/web/dist missing or stale)…')
try { try {
execSync('pnpm --filter @mind-map/web build', { execSync('pnpm --filter @mind-map/web build', {
@ -187,14 +223,7 @@ export function createRuntime() {
await new Promise((r) => setTimeout(r, 400)) await new Promise((r) => setTimeout(r, 400))
} }
const entryAbs = join(mindMapRoot, 'apps/server/src/index.ts') const packaged = isPackagedInstall()
const distAbs = join(mindMapRoot, 'apps/server/dist/index.js')
const useTs = existsSync(entryAbs)
const scriptAbs = useTs ? entryAbs : distAbs
if (!existsSync(scriptAbs)) {
throw new Error(`mind-map server entry not found under ${mindMapRoot}`)
}
const env = { const env = {
...process.env, ...process.env,
PORT: String(port), PORT: String(port),
@ -205,18 +234,35 @@ export function createRuntime() {
} }
if (dataDir) env.DATA_DIR = dataDir if (dataDir) env.DATA_DIR = dataDir
const scriptRel = useTs ? 'apps/server/src/index.ts' : 'apps/server/dist/index.js' let cwd
let args let args
if (useTs) { if (packaged) {
const tsxCli = resolveTsxCliRel() cwd = pluginRoot
if (!tsxCli) throw new Error('tsx not found; run pnpm install in mind-map') env.MINDMAP_ROOT = pluginRoot
args = [tsxCli, scriptRel] env.MINDMAP_WEB_DIST = join(pluginRoot, 'bundle', 'web')
args = [bundledServer()]
console.log('[dsh-mind-map] starting packaged bundle/server.mjs')
} else { } else {
args = [scriptRel] cwd = mindMapRoot
const entryAbs = join(mindMapRoot, 'apps/server/src/index.ts')
const distAbs = join(mindMapRoot, 'apps/server/dist/index.js')
const useTs = existsSync(entryAbs)
const scriptAbs = useTs ? entryAbs : distAbs
if (!existsSync(scriptAbs)) {
throw new Error(`mind-map server entry not found under ${mindMapRoot}`)
}
const scriptRel = useTs ? 'apps/server/src/index.ts' : 'apps/server/dist/index.js'
if (useTs) {
const tsxCli = resolveTsxCliRel()
if (!tsxCli) throw new Error('tsx not found; run pnpm install in mind-map')
args = [tsxCli, scriptRel]
} else {
args = [scriptRel]
}
} }
child = spawn(process.execPath, args, { child = spawn(process.execPath, args, {
cwd: mindMapRoot, cwd,
env, env,
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true, windowsHide: true,
@ -277,8 +323,9 @@ export function createRuntime() {
...last, ...last,
running: Boolean(child && !child.killed), running: Boolean(child && !child.killed),
pid: child?.pid || null, pid: child?.pid || null,
packaged: isPackagedInstall(),
} }
} }
return { start, stop, status, mindMapRoot } return { start, stop, status, mindMapRoot, pluginRoot }
} }

View file

@ -11,11 +11,14 @@
}, },
"files": [ "files": [
"lib", "lib",
"bundle",
"cordis.patch.yml", "cordis.patch.yml",
"README.md" "README.md",
"INSTALL.md"
], ],
"scripts": { "scripts": {
"test": "node --test test/*.test.js" "test": "node --test test/*.test.js",
"pack": "node scripts/pack-bundle.mjs"
}, },
"dsh": { "dsh": {
"bundle": { "bundle": {

View file

@ -0,0 +1,71 @@
/**
* Pack prebuilt web UI + server into packages/dsh-plugin/bundle
* so `dsh plugin add github:…#path:packages/dsh-plugin` works without monorepo.
*/
import { execSync } from "node:child_process";
import {
cpSync,
existsSync,
mkdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as esbuild from "esbuild";
const pluginRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const repoRoot = join(pluginRoot, "../..");
const bundleRoot = join(pluginRoot, "bundle");
const webOut = join(bundleRoot, "web");
const serverOut = join(bundleRoot, "server.mjs");
function run(cmd, cwd = repoRoot) {
console.log(`[pack] ${cmd}`);
execSync(cmd, { cwd, stdio: "inherit", env: process.env, windowsHide: true });
}
// 1) Build workspace libs + web
run("pnpm --filter @mind-map/shared build");
run("pnpm --filter @mind-map/graph-core build");
run("pnpm --filter @mind-map/graph-store build");
run("pnpm --filter @mind-map/layout-engine build");
// dream-skin ships TS+JSON; esbuild resolves it without a separate tsc emit
run("pnpm --filter @mind-map/web build");
const webDist = join(repoRoot, "apps/web/dist");
if (!existsSync(join(webDist, "index.html"))) {
throw new Error("apps/web/dist/index.html missing after build");
}
// 2) Fresh bundle dir
rmSync(bundleRoot, { recursive: true, force: true });
mkdirSync(webOut, { recursive: true });
cpSync(webDist, webOut, { recursive: true });
// 3) Bundle server (workspace + npm deps inlined; node builtins stay external)
console.log("[pack] esbuild apps/server → bundle/server.mjs");
await esbuild.build({
entryPoints: [join(repoRoot, "apps/server/src/index.ts")],
bundle: true,
platform: "node",
format: "esm",
outfile: serverOut,
packages: "bundle",
banner: {
js: "import { createRequire as __mmCr } from 'node:module'; const require = __mmCr(import.meta.url);",
},
logLevel: "info",
});
if (!existsSync(serverOut)) {
throw new Error("bundle/server.mjs was not produced");
}
writeFileSync(
join(bundleRoot, "README.txt"),
"Prebuilt assets for remote dsh plugin install. Regenerated by: node packages/dsh-plugin/scripts/pack-bundle.mjs\n",
"utf8",
);
console.log("[pack] OK →", bundleRoot);

3
pnpm-lock.yaml generated
View file

@ -8,6 +8,9 @@ importers:
.: .:
devDependencies: devDependencies:
esbuild:
specifier: ^0.25.12
version: 0.25.12
tsx: tsx:
specifier: ^4.19.3 specifier: ^4.19.3
version: 4.23.13 version: 4.23.13