dsh-prompt-customizer 0.12.0 → 2.1.2
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 +21 -21
- package/README.en.md +7 -6
- package/README.i18n.yaml +2 -2
- package/README.md +7 -6
- package/build.mjs +236 -236
- package/client/client.js +2226 -1395
- package/client/client.js.map +4 -4
- package/cordis.patch.yml +11 -11
- package/docs/adr/0001-remove-catalog-pool.md +27 -0
- package/docs/adr/0002-hide-identity-name-source.md +23 -0
- package/docs/adr/0003-destructive-updates-allowed.md +18 -0
- package/docs/adr/0004-generalized-filter-model.md +17 -0
- package/docs/adr/0005-universal-prompt-takeover.md +109 -0
- package/docs/design/prompt-control-consensus.md +103 -0
- package/docs/guide.md +25 -11
- package/docs/testing/feature-matrix.md +142 -0
- package/docs/upgrades/1.0.0-sqlite-breaking.md +26 -0
- package/docs/upgrades/1.4.0-generalized-model.md +23 -0
- package/docs/upgrades/1.4.1-web-preview-fixes.md +15 -0
- package/docs/upgrades/2.0.0-remove-workspace-level.md +16 -0
- package/docs/upgrades/2.0.1-context-fixes.md +6 -0
- package/docs/upgrades/2.0.10-import-and-skill.md +6 -0
- package/docs/upgrades/2.0.11-regression-fixes.md +7 -0
- package/docs/upgrades/2.0.12-generative-tool-tests.md +5 -0
- package/docs/upgrades/2.0.13-runtime-contract.md +8 -0
- package/docs/upgrades/2.0.14-tool-execution-guard.md +5 -0
- package/docs/upgrades/2.0.15-mask-pool-regression.md +5 -0
- package/docs/upgrades/2.0.16-regression-cleanup.md +6 -0
- package/docs/upgrades/2.0.17-unhide-source.md +5 -0
- package/docs/upgrades/2.0.18-unhide-cleanup.md +4 -0
- package/docs/upgrades/2.0.19-mcp-json-edit.md +4 -0
- package/docs/upgrades/2.0.2-generic-context-filter.md +6 -0
- package/docs/upgrades/2.0.20-preset-isolation.md +4 -0
- package/docs/upgrades/2.0.21-isolation-restore-test.md +4 -0
- package/docs/upgrades/2.0.22-source-immutability.md +3 -0
- package/docs/upgrades/2.0.3-skill-summary.md +6 -0
- package/docs/upgrades/2.0.4-tool-draft.md +5 -0
- package/docs/upgrades/2.0.5-tool-catalog-robustness.md +13 -0
- package/docs/upgrades/2.0.6-tool-restrict.md +6 -0
- package/docs/upgrades/2.0.7-tool-stability.md +5 -0
- package/docs/upgrades/2.0.8-catalog-row-stability.md +4 -0
- package/docs/upgrades/2.0.9-tool-and-write.md +8 -0
- package/docs/upgrades/2.1.0-universal-prompt-takeover.md +41 -0
- package/docs/upgrades/2.1.1-tool-shadow-detection.md +48 -0
- package/docs/upgrades/2.1.2-tool-catalog-scope.md +55 -0
- package/docs/upgrades/README.md +145 -1
- package/docs/upgrades/rc2-migration.md +70 -70
- package/lib/index.js +5474 -3041
- package/lib/index.js.map +4 -4
- package/package.json +97 -97
- package/scripts/e2e-report.json +221 -0
- package/scripts/e2e-terminal.mjs +210 -0
- package/scripts/fixtures/rc2-mode.mjs +12 -12
- package/scripts/fixtures/rc2-preview.snapshot.json +35 -35
- package/scripts/fixtures/rc2-runtime.mjs +85 -85
- package/scripts/fixtures/rc2.cordis.yml +31 -31
- package/scripts/fixtures/rc2.overlay.yml +9 -9
- package/scripts/simulate-rescan.mjs +184 -180
- package/scripts/smoke-client.mjs +382 -382
- package/scripts/smoke-host.mjs +130 -130
- package/scripts/test-skill-manager.mjs +170 -170
- package/scripts/verify-apply-all.mjs +64 -0
- package/scripts/verify-effect.mjs +153 -0
- package/scripts/verify-mcp-edit-ext.mjs +48 -0
- package/scripts/verify-mcp-edit.mjs +45 -0
- package/scripts/verify-workspace-mcp.mjs +62 -0
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 DreamsTOF
|
|
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.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DreamsTOF
|
|
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.en.md
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) plugin. It adds an independent "Capabilities" panel to the sidebar that brings three otherwise configuration-heavy jobs into one visual surface:
|
|
33
33
|
|
|
34
|
-
- **Prompt customization**: block, replace, inject and reorder system-prompt sections by name;
|
|
34
|
+
- **Prompt customization**: block, replace, inject and reorder system-prompt sections by name + source — across every scope, including session-level sections that plugins register dynamically on the active agent's own scope (universal takeover, ADR-0005);
|
|
35
35
|
- **Skill collection management**: import, group and toggle skills;
|
|
36
36
|
- **MCP server integration**: add MCP servers and apply preset-level or tool-level masking.
|
|
37
37
|
|
|
@@ -39,9 +39,11 @@ All changes go through documented plugin extension points. No DSH source is modi
|
|
|
39
39
|
|
|
40
40
|
## Features
|
|
41
41
|
|
|
42
|
-
- **Prompt sections**: block / replace / inject / drag-reorder prompt sections, with a live preview of the final system prompt the model receives.
|
|
42
|
+
- **Prompt sections**: block / replace / inject / drag-reorder prompt sections, with a live preview of the final system prompt the model receives. The editable inventory and preview merge the selected preset with the same-preset active agent's real assembly, so plugin-injected session-level sections are enumerated and controllable. The host also exposes a read-only `/api/prompt-customizer/session` route reporting the latest active root session's own real assembly (there is no separate "Current session" panel tab).
|
|
43
43
|
- **Phase and preset layering**: bootstrap, resident and compaction-controlled phases each have their own section lists, ordering space and tool catalog; the top preset selector scopes edits to one agent preset.
|
|
44
|
+
- **Single-preset editing (2.0.0)**: prompt/tool customization is stored and applied only per **agent preset**. The workspace-level prompt/tool layer has been removed (source archived, data dormant, neither read nor backed up). Workspaces still apply to MCP / skills / cwd.
|
|
44
45
|
- **Tool catalog**: hide or add back tools per phase, with a "model-visible / registry total" comparison that also handles PTC / Code Mode `run_code` wrapping.
|
|
46
|
+
- **Tool prompt sections**: the Tools view links each `tool:*` prompt section to its tool group (for example `tool:jobs` covers `job_kill/job_list/job_output`), while the Prompt view can block / replace / reorder those sections independently. Tool descriptions stay read-only and JSON Schema is not part of prompt editing.
|
|
45
47
|
- **Skill management**: skill bundles, ZIP / folder import, global and preset toggles, skill-root and membership health checks.
|
|
46
48
|
- **MCP management**: paste JSON / YAML to connect, preset-owned server rows, global / preset masking, per-tool enable/disable, recommended templates.
|
|
47
49
|
- **Prompt variables**: built-in date / host / environment facts plus `process.env` mapping with a secret blocklist.
|
|
@@ -74,7 +76,7 @@ Or install from a local checkout:
|
|
|
74
76
|
dsh plugin --profile web add /path/to/dsh-prompt-customizer
|
|
75
77
|
```
|
|
76
78
|
|
|
77
|
-
Current release
|
|
79
|
+
Current release **2.1.2** (single-preset editing; **universal system-prompt takeover** — the inventory and preview merge the selected preset with the same-preset active agent's real assembly so plugin-injected session-level sections can be enumerated and blocked; **tool catalog matches the live session** — the visible tool list now comes from `ctx.tools.schemas(agent)` instead of the preset-only assembly; **same-name shadow detection** — inherited tools displaced by a same-name plugin tool enter the read-only masked pool, and plugin-only session-level tools are listed honestly as such; DSH runtime tool/prompt contracts, mask pool, workspace skill status, provider summary cache and write serialization; MCP global edit keeps JSON/YAML; live agent preset resolution via `composedPreset`; A/B preset, uninstall-restore and source-immutability regressions), targeting **DSH 0.2.2-rc.1**. Version compatibility, per-release changes and migration notes live in [docs/upgrades/README.md](docs/upgrades/README.md).
|
|
78
80
|
|
|
79
81
|
## Quick start
|
|
80
82
|
|
|
@@ -86,9 +88,8 @@ Prompt sections and tools can be configured separately for the bootstrap, compac
|
|
|
86
88
|
|
|
87
89
|
## Where the configuration lives
|
|
88
90
|
|
|
89
|
-
- `~/.dsh/prompt-customizer/
|
|
90
|
-
-
|
|
91
|
-
- Skill and MCP ledgers live under the plugin's directory in `$DSH_HOME` and the relevant workspace directories.
|
|
91
|
+
- **A single SQLite file**: `~/.dsh/prompt-customizer/prompt-customizer.db` (WAL, using the built-in `node:sqlite`, no external dependency). Preset customizations and the MCP / skill ledgers live in this one DB; workspace-level prompt/tool data is retained as dormant fields without reads or writes. Host config files (`cordis.patch.yml` / `.mcp.json`) and per-skill-root `.bundles.json` remain files.
|
|
92
|
+
- **Breaking change (1.0.0)**: the old `config.yaml` / `catalog.yaml` and JSON ledgers are **no longer read or migrated** (files are left untouched); upgrading to 1.0.0 requires **re-configuring**. See [docs/upgrades/1.0.0-sqlite-breaking.md](docs/upgrades/1.0.0-sqlite-breaking.md).
|
|
92
93
|
|
|
93
94
|
## Development
|
|
94
95
|
|
package/README.i18n.yaml
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
en: README.en.md
|
|
2
|
-
zh-CN: README.md
|
|
1
|
+
en: README.en.md
|
|
2
|
+
zh-CN: README.md
|
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
|
|
44
44
|
一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)插件。它在侧边栏提供一个独立的「能力」面板,把三件平时要改配置、翻源码才能完成的事集中到可视化界面:
|
|
45
45
|
|
|
46
|
-
-
|
|
46
|
+
- **提示词定制**:按名字 + 来源屏蔽、替换、注入、排序系统提示词段;
|
|
47
47
|
- **技能集合管理**:导入、归组、开关技能;
|
|
48
48
|
- **MCP Server 接入**:添加 MCP Server,并按预设或工具粒度做遮蔽与启停。
|
|
49
49
|
|
|
@@ -51,9 +51,11 @@
|
|
|
51
51
|
|
|
52
52
|
## 核心能力
|
|
53
53
|
|
|
54
|
-
- **提示词段管理**:屏蔽 / 替换 / 注入 /
|
|
54
|
+
- **提示词段管理**:屏蔽 / 替换 / 注入 / 拖动排序提示词段,右栏实时预览模型最终看到的系统提示词;**全域接管**:任何插件在任意作用域(含 `agent/created` 里动态注册的会话级段)注入的系统提示词段,都会出现在可编辑清单里并可被屏蔽 / 替换 / 排序。宿主侧另有 `/api/prompt-customizer/session` 只读路由,按最新活动根会话自己的 agent 作用域展示真实提示词与工具目录(面板本身不单独开「当前会话」Tab)。
|
|
55
55
|
- **阶段与预设分层**:引导期、常驻期、压缩受控期各有独立的段名单、排序空间与工具目录;顶部按 agent 预设切换编辑目标,某个预设的定制只对该预设生效。
|
|
56
|
+
- **单预设编辑(2.0.0)**:提示词 / 工具定制只按 **agent 预设** 保存与生效;工作区级提示词/工具层已移除(源码归档、数据休眠,不读不写不备份)。工作区概念仍用于 MCP / 技能 / cwd。
|
|
56
57
|
- **工具目录**:按阶段隐藏或加回工具,面板同时显示「模型可见 / 注册表总数」,PTC / Code Mode 的 `run_code` 包装也能正确对照。
|
|
58
|
+
- **工具提示词段**:工具页显示 `tool:*` 提示词段与工具组的关联(如 `tool:jobs` → `job_kill/job_list/job_output`),提示词页可独立屏蔽 / 替换 / 排序;工具 description 只读展示,JSON Schema 不纳入提示词编辑。
|
|
57
59
|
- **技能管理**:技能包归组、ZIP / 文件夹导入、全局 / 预设两层开关、技能根与成员一致性体检。
|
|
58
60
|
- **MCP 管理**:粘贴 JSON / YAML 接入、预设专属 Server 行、全局 / 预设双层遮蔽、工具级启停、推荐配置模板。
|
|
59
61
|
- **提示词变量**:内置日期、主机、环境等变量,支持 `process.env` 映射与密钥黑名单。
|
|
@@ -86,7 +88,7 @@ dsh plugin --profile desktop add dsh-prompt-customizer
|
|
|
86
88
|
dsh plugin --profile web add /path/to/dsh-prompt-customizer
|
|
87
89
|
```
|
|
88
90
|
|
|
89
|
-
|
|
91
|
+
当前版本 **2.1.2**(单预设编辑;**全域系统提示词接管**:清单与预览并入同预设活动 agent 的真实装配,插件动态注入的会话级段可枚举、可屏蔽;**工具目录与真实会话同源**:右栏可见工具目录改用 `ctx.tools.schemas(agent)`,不再只显示预设层;**同名遮蔽探测**:被插件同名工具顶掉的原生工具无条件进入只读遮蔽池,插件纯新增的会话级工具也如实列出;工具屏蔽池、工作区技能状态、汇总缓存和写队列回归已补齐;MCP 全局编辑支持 JSON/YAML;活跃 Agent 预设按 `composedPreset` 精确归位;A/B 预设提示词与工具隔离、卸载恢复及原文不可变回归)。版本对应关系、逐版变更和适配记录见 [docs/upgrades/README.md](docs/upgrades/README.md)。
|
|
90
92
|
|
|
91
93
|
## 快速上手
|
|
92
94
|
|
|
@@ -98,9 +100,8 @@ dsh plugin --profile web add /path/to/dsh-prompt-customizer
|
|
|
98
100
|
|
|
99
101
|
## 配置位置
|
|
100
102
|
|
|
101
|
-
-
|
|
102
|
-
-
|
|
103
|
-
- 技能与 MCP 的账本、技能包记录等位于 `$DSH_HOME` 下本插件目录及工作区对应目录。
|
|
103
|
+
- **统一为单文件 SQLite**:`~/.dsh/prompt-customizer/prompt-customizer.db`(WAL,使用 Node 内建 `node:sqlite`,无外部依赖)。预设定制、MCP 与技能各账本都在这一个库里;工作区级提示词/工具数据作为休眠字段保留但不读不写。宿主配置文件(`cordis.patch.yml` / `.mcp.json`)与技能根下的 `.bundles.json` 仍保持文件。
|
|
104
|
+
- **破坏式更新(1.0.0)**:旧版 `config.yaml` / `catalog.yaml` 及各 JSON 账本**不再读取、不迁移**(文件原样保留);升级到 1.0.0 需**重新配置**。详见 [docs/upgrades/1.0.0-sqlite-breaking.md](docs/upgrades/1.0.0-sqlite-breaking.md)。
|
|
104
105
|
|
|
105
106
|
## 开发
|
|
106
107
|
|
package/build.mjs
CHANGED
|
@@ -1,236 +1,236 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-prompt-customizer — build script.
|
|
3
|
-
*
|
|
4
|
-
* Two bundles from one esbuild run:
|
|
5
|
-
*
|
|
6
|
-
* lib/index.js host half ESM, node platform, self-contained
|
|
7
|
-
* lib/client.js browser half CJS, browser platform, wrapped in the
|
|
8
|
-
* `window.__ModuleLoader__.load` factory contract
|
|
9
|
-
*
|
|
10
|
-
* The host half must stay **resolvable from an installed location**, i.e. from
|
|
11
|
-
* inside `~/.dsh/profiles/<p>/node_modules/`. That rules out importing most
|
|
12
|
-
* `@deepseek-ai/*` packages at runtime: DSH ships those as source only (their
|
|
13
|
-
* `lib/` holds `.d.ts` plus a couple of hand-built entries, no `lib/index.js`),
|
|
14
|
-
* and resolves them at runtime through tsx + `tsconfig.base.json` `paths`.
|
|
15
|
-
* tsx only applies `paths` to importers *outside* node_modules, so a plugin
|
|
16
|
-
* that was installed with `dsh plugin add` gets plain node resolution and
|
|
17
|
-
* lands on a missing file. See vendor/README.md for the full rule and
|
|
18
|
-
* `assertHostExternals()` below for the guard that enforces it.
|
|
19
|
-
*
|
|
20
|
-
* Usage: node build.mjs
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { createRequire } from 'node:module'
|
|
24
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
25
|
-
import { resolve, dirname, join, basename } from 'node:path'
|
|
26
|
-
import { fileURLToPath } from 'node:url'
|
|
27
|
-
|
|
28
|
-
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
29
|
-
// 可选:指向一份 DSH 检出,用于在没装 esbuild 时借用它 pnpm store 里的副本。
|
|
30
|
-
// 缺省时自动探测同级的 `deepseek-harness` 检出与其它同级目录(开发机便利)。
|
|
31
|
-
const DSH_CHECKOUT = process.env.DSH_CHECKOUT ?? ''
|
|
32
|
-
const PLUGIN_ID = 'dsh-prompt-customizer'
|
|
33
|
-
|
|
34
|
-
/** 可能含 pnpm store 的检出根:显式 env、同级 deepseek-harness、同级其它目录。 */
|
|
35
|
-
function checkoutCandidates() {
|
|
36
|
-
const parent = resolve(HERE, '..')
|
|
37
|
-
const roots = []
|
|
38
|
-
if (DSH_CHECKOUT !== '') roots.push(DSH_CHECKOUT)
|
|
39
|
-
roots.push(join(parent, 'deepseek-harness'))
|
|
40
|
-
try {
|
|
41
|
-
for (const entry of readdirSync(parent, { withFileTypes: true })) {
|
|
42
|
-
if (entry.isDirectory()) roots.push(join(parent, entry.name))
|
|
43
|
-
}
|
|
44
|
-
} catch { /* 无同级目录 */ }
|
|
45
|
-
return [...new Set(roots)]
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Resolve esbuild.
|
|
50
|
-
*
|
|
51
|
-
* Tiers, in order — the first two keep this plugin buildable both for a
|
|
52
|
-
* fresh clone (`pnpm install` puts esbuild in node_modules) and for a
|
|
53
|
-
* contributor hacking inside a DSH checkout:
|
|
54
|
-
*
|
|
55
|
-
* 1. the plugin's own `node_modules` (normal install path; esbuild is a
|
|
56
|
-
* devDependency so `pnpm install` provides it),
|
|
57
|
-
* 2. a local DSH checkout's pnpm store (contributor convenience — pnpm's
|
|
58
|
-
* strict layout keeps esbuild out of the root node_modules, so scan
|
|
59
|
-
* `node_modules/.pnpm/esbuild@*` and take the highest version),
|
|
60
|
-
* 3. a clear, actionable error.
|
|
61
|
-
*
|
|
62
|
-
* Tier 3 matters: a bare `require('esbuild')` failure message does not tell
|
|
63
|
-
* the user to run `pnpm install`.
|
|
64
|
-
*/
|
|
65
|
-
function loadEsbuild() {
|
|
66
|
-
const localRequire = createRequire(resolve(HERE, 'package.json'))
|
|
67
|
-
try {
|
|
68
|
-
return localRequire('esbuild')
|
|
69
|
-
} catch {
|
|
70
|
-
// Not installed locally; fall through to the checkout scan.
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
for (const root of checkoutCandidates()) {
|
|
74
|
-
const store = join(root, 'node_modules', '.pnpm')
|
|
75
|
-
if (!existsSync(store)) continue
|
|
76
|
-
const candidates = []
|
|
77
|
-
for (const entry of readdirSync(store)) {
|
|
78
|
-
if (!entry.startsWith('esbuild@')) continue
|
|
79
|
-
candidates.push(join(store, entry, 'node_modules', 'esbuild'))
|
|
80
|
-
}
|
|
81
|
-
if (candidates.length === 0) continue
|
|
82
|
-
const pick = candidates.sort().at(-1)
|
|
83
|
-
try {
|
|
84
|
-
return createRequire(resolve(pick, 'package.json'))(pick)
|
|
85
|
-
} catch { /* store 里没有可用的平台二进制:试下一个根 */ }
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
throw new Error(
|
|
89
|
-
'dsh-prompt-customizer: cannot find esbuild.\n'
|
|
90
|
-
+ ' Run `pnpm install` in this directory (esbuild is a devDependency).\n'
|
|
91
|
-
+ ' Or set DSH_CHECKOUT to a DSH checkout to borrow its copy.\n'
|
|
92
|
-
+ ` Checked: ${checkoutCandidates().join(', ')}`,
|
|
93
|
-
)
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const esbuild = loadEsbuild()
|
|
97
|
-
|
|
98
|
-
/** Platform packages + react come from the DSH module table at runtime. */
|
|
99
|
-
const CLIENT_EXTERNAL = [
|
|
100
|
-
'react',
|
|
101
|
-
'react/jsx-runtime',
|
|
102
|
-
'react-dom',
|
|
103
|
-
'react-dom/client',
|
|
104
|
-
]
|
|
105
|
-
|
|
106
|
-
/** Browser half: one CJS factory registered with the host module loader. */
|
|
107
|
-
const clientBundle = {
|
|
108
|
-
entryPoints: [resolve(HERE, 'src/client/index.ts')],
|
|
109
|
-
// 浏览器半身落在 client/:package.json 的 exports.\"./client\" 指向它。
|
|
110
|
-
outfile: resolve(HERE, 'client/client.js'),
|
|
111
|
-
bundle: true,
|
|
112
|
-
format: 'cjs',
|
|
113
|
-
platform: 'browser',
|
|
114
|
-
target: ['es2020'],
|
|
115
|
-
jsx: 'automatic',
|
|
116
|
-
sourcemap: true,
|
|
117
|
-
logLevel: 'info',
|
|
118
|
-
external: CLIENT_EXTERNAL,
|
|
119
|
-
// Everything under @deepseek-ai/ stays a runtime require.
|
|
120
|
-
plugins: [{
|
|
121
|
-
name: 'pc-external-platform',
|
|
122
|
-
setup(build) {
|
|
123
|
-
build.onResolve({ filter: /^@deepseek-ai\// }, args => ({ path: args.path, external: true }))
|
|
124
|
-
},
|
|
125
|
-
}],
|
|
126
|
-
banner: {
|
|
127
|
-
js: [
|
|
128
|
-
`window.__ModuleLoader__.load({ id: ${JSON.stringify(PLUGIN_ID)}, factory: (require) => {`,
|
|
129
|
-
'var module = { exports: {} };',
|
|
130
|
-
'var exports = module.exports;',
|
|
131
|
-
'Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });',
|
|
132
|
-
].join('\n'),
|
|
133
|
-
},
|
|
134
|
-
footer: {
|
|
135
|
-
js: 'return module.exports; } });',
|
|
136
|
-
},
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* The only bare specifiers the host bundle may leave to runtime resolution.
|
|
141
|
-
*
|
|
142
|
-
* A plugin installed into a profile's node_modules can only resolve packages
|
|
143
|
-
* that DSH actually ships runtime JS for. `@deepseek-ai/cordis` does. Anything
|
|
144
|
-
* else under `@deepseek-ai/` must be vendored into `vendor/` instead — see
|
|
145
|
-
* vendor/README.md.
|
|
146
|
-
*
|
|
147
|
-
* This is an allowlist rather than "whatever resolved at build time" on
|
|
148
|
-
* purpose: the build runs from a source checkout where *every* `@deepseek-ai/*`
|
|
149
|
-
* name resolves to a source dir, so a build-time check would pass and the
|
|
150
|
-
* installed plugin would still crash at startup.
|
|
151
|
-
*/
|
|
152
|
-
const HOST_RUNTIME_EXTERNAL_ALLOWLIST = new Set([
|
|
153
|
-
'@deepseek-ai/cordis',
|
|
154
|
-
])
|
|
155
|
-
|
|
156
|
-
/** Host half: ESM, self-contained except node builtins and the allowlist above. */
|
|
157
|
-
const hostBundle = {
|
|
158
|
-
entryPoints: [resolve(HERE, 'src/host.ts')],
|
|
159
|
-
outfile: resolve(HERE, 'lib/index.js'),
|
|
160
|
-
bundle: true,
|
|
161
|
-
format: 'esm',
|
|
162
|
-
platform: 'node',
|
|
163
|
-
target: 'node22',
|
|
164
|
-
sourcemap: true,
|
|
165
|
-
logLevel: 'info',
|
|
166
|
-
// node:* builtins stay external. js-yaml is bundled in on purpose: the
|
|
167
|
-
// plugin's node_modules is a junction into the DSH profile (so @deepseek-ai/*
|
|
168
|
-
// resolves to the very same module instances the host uses), and a junction
|
|
169
|
-
// cannot also hold a private js-yaml copy.
|
|
170
|
-
//
|
|
171
|
-
// `@deepseek-ai/*` is external *at bundle time* only so esbuild keeps the
|
|
172
|
-
// names verbatim; `assertHostExternals()` then checks every surviving name
|
|
173
|
-
// against the allowlist, so an unvendored dependency fails the build here
|
|
174
|
-
// instead of at plugin startup on someone else's machine.
|
|
175
|
-
external: [],
|
|
176
|
-
// js-yaml is inlined (see above) and is CJS: it calls bare `require('fs')`.
|
|
177
|
-
// In an ESM bundle that throws "Dynamic require ... is not supported", so
|
|
178
|
-
// hand the bundle a real CommonJS require bound to its own URL.
|
|
179
|
-
banner: {
|
|
180
|
-
js: [
|
|
181
|
-
"import { createRequire as __pcCreateRequire } from 'node:module';",
|
|
182
|
-
'const require = __pcCreateRequire(import.meta.url);',
|
|
183
|
-
].join('\n'),
|
|
184
|
-
},
|
|
185
|
-
plugins: [{
|
|
186
|
-
name: 'pc-external-platform',
|
|
187
|
-
setup(build) {
|
|
188
|
-
build.onResolve({ filter: /^(@deepseek-ai\/|node:)/ }, args => ({ path: args.path, external: true }))
|
|
189
|
-
},
|
|
190
|
-
}],
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Fail the build if the host bundle still hands an unresolvable specifier to
|
|
195
|
-
* runtime resolution.
|
|
196
|
-
*
|
|
197
|
-
* Catches exactly the regression that motivated `vendor/`: adding
|
|
198
|
-
* `import { X } from '@deepseek-ai/dsh-llm'` to a host module looks fine on a
|
|
199
|
-
* dev machine (esbuild externalises it, the source checkout resolves it, the
|
|
200
|
-
* smoke test under tsx passes) and then throws ERR_MODULE_NOT_FOUND for every
|
|
201
|
-
* user who installed the plugin.
|
|
202
|
-
*/
|
|
203
|
-
function assertHostExternals(outfile) {
|
|
204
|
-
const source = readFileSync(outfile, 'utf8')
|
|
205
|
-
const specifiers = new Set()
|
|
206
|
-
for (const m of source.matchAll(/(?:^|[;\n])\s*(?:import|export)[\s\S]*?from\s*["']([^"']+)["']/g)) {
|
|
207
|
-
specifiers.add(m[1])
|
|
208
|
-
}
|
|
209
|
-
for (const m of source.matchAll(/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g)) {
|
|
210
|
-
specifiers.add(m[1])
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const violations = [...specifiers].filter((spec) => {
|
|
214
|
-
if (spec.startsWith('node:')) return false
|
|
215
|
-
return !HOST_RUNTIME_EXTERNAL_ALLOWLIST.has(spec)
|
|
216
|
-
})
|
|
217
|
-
|
|
218
|
-
if (violations.length > 0) {
|
|
219
|
-
throw new Error(
|
|
220
|
-
'dsh-prompt-customizer: host bundle imports packages that an installed plugin cannot resolve.\n'
|
|
221
|
-
+ violations.map(v => ` - ${v}`).join('\n')
|
|
222
|
-
+ '\n\n'
|
|
223
|
-
+ 'DSH ships @deepseek-ai/* as source only; a plugin inside a profile\'s\n'
|
|
224
|
-
+ 'node_modules gets plain node resolution and finds no lib/index.js.\n'
|
|
225
|
-
+ 'Vendor the leaf modules you need into vendor/ (see vendor/README.md),\n'
|
|
226
|
-
+ `or add the name to HOST_RUNTIME_EXTERNAL_ALLOWLIST in ${basename(fileURLToPath(import.meta.url))}\n`
|
|
227
|
-
+ 'after verifying the package really ships runtime JS.',
|
|
228
|
-
)
|
|
229
|
-
}
|
|
230
|
-
return [...specifiers]
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
await Promise.all([esbuild.build(clientBundle), esbuild.build(hostBundle)])
|
|
234
|
-
const hostExternals = assertHostExternals(resolve(HERE, 'lib/index.js'))
|
|
235
|
-
console.log('[dsh-prompt-customizer] built lib/index.js + client/client.js')
|
|
236
|
-
console.log(`[dsh-prompt-customizer] host runtime imports: ${hostExternals.length === 0 ? '(none)' : hostExternals.join(', ')}`)
|
|
1
|
+
/**
|
|
2
|
+
* dsh-prompt-customizer — build script.
|
|
3
|
+
*
|
|
4
|
+
* Two bundles from one esbuild run:
|
|
5
|
+
*
|
|
6
|
+
* lib/index.js host half ESM, node platform, self-contained
|
|
7
|
+
* lib/client.js browser half CJS, browser platform, wrapped in the
|
|
8
|
+
* `window.__ModuleLoader__.load` factory contract
|
|
9
|
+
*
|
|
10
|
+
* The host half must stay **resolvable from an installed location**, i.e. from
|
|
11
|
+
* inside `~/.dsh/profiles/<p>/node_modules/`. That rules out importing most
|
|
12
|
+
* `@deepseek-ai/*` packages at runtime: DSH ships those as source only (their
|
|
13
|
+
* `lib/` holds `.d.ts` plus a couple of hand-built entries, no `lib/index.js`),
|
|
14
|
+
* and resolves them at runtime through tsx + `tsconfig.base.json` `paths`.
|
|
15
|
+
* tsx only applies `paths` to importers *outside* node_modules, so a plugin
|
|
16
|
+
* that was installed with `dsh plugin add` gets plain node resolution and
|
|
17
|
+
* lands on a missing file. See vendor/README.md for the full rule and
|
|
18
|
+
* `assertHostExternals()` below for the guard that enforces it.
|
|
19
|
+
*
|
|
20
|
+
* Usage: node build.mjs
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createRequire } from 'node:module'
|
|
24
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
25
|
+
import { resolve, dirname, join, basename } from 'node:path'
|
|
26
|
+
import { fileURLToPath } from 'node:url'
|
|
27
|
+
|
|
28
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
29
|
+
// 可选:指向一份 DSH 检出,用于在没装 esbuild 时借用它 pnpm store 里的副本。
|
|
30
|
+
// 缺省时自动探测同级的 `deepseek-harness` 检出与其它同级目录(开发机便利)。
|
|
31
|
+
const DSH_CHECKOUT = process.env.DSH_CHECKOUT ?? ''
|
|
32
|
+
const PLUGIN_ID = 'dsh-prompt-customizer'
|
|
33
|
+
|
|
34
|
+
/** 可能含 pnpm store 的检出根:显式 env、同级 deepseek-harness、同级其它目录。 */
|
|
35
|
+
function checkoutCandidates() {
|
|
36
|
+
const parent = resolve(HERE, '..')
|
|
37
|
+
const roots = []
|
|
38
|
+
if (DSH_CHECKOUT !== '') roots.push(DSH_CHECKOUT)
|
|
39
|
+
roots.push(join(parent, 'deepseek-harness'))
|
|
40
|
+
try {
|
|
41
|
+
for (const entry of readdirSync(parent, { withFileTypes: true })) {
|
|
42
|
+
if (entry.isDirectory()) roots.push(join(parent, entry.name))
|
|
43
|
+
}
|
|
44
|
+
} catch { /* 无同级目录 */ }
|
|
45
|
+
return [...new Set(roots)]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve esbuild.
|
|
50
|
+
*
|
|
51
|
+
* Tiers, in order — the first two keep this plugin buildable both for a
|
|
52
|
+
* fresh clone (`pnpm install` puts esbuild in node_modules) and for a
|
|
53
|
+
* contributor hacking inside a DSH checkout:
|
|
54
|
+
*
|
|
55
|
+
* 1. the plugin's own `node_modules` (normal install path; esbuild is a
|
|
56
|
+
* devDependency so `pnpm install` provides it),
|
|
57
|
+
* 2. a local DSH checkout's pnpm store (contributor convenience — pnpm's
|
|
58
|
+
* strict layout keeps esbuild out of the root node_modules, so scan
|
|
59
|
+
* `node_modules/.pnpm/esbuild@*` and take the highest version),
|
|
60
|
+
* 3. a clear, actionable error.
|
|
61
|
+
*
|
|
62
|
+
* Tier 3 matters: a bare `require('esbuild')` failure message does not tell
|
|
63
|
+
* the user to run `pnpm install`.
|
|
64
|
+
*/
|
|
65
|
+
function loadEsbuild() {
|
|
66
|
+
const localRequire = createRequire(resolve(HERE, 'package.json'))
|
|
67
|
+
try {
|
|
68
|
+
return localRequire('esbuild')
|
|
69
|
+
} catch {
|
|
70
|
+
// Not installed locally; fall through to the checkout scan.
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const root of checkoutCandidates()) {
|
|
74
|
+
const store = join(root, 'node_modules', '.pnpm')
|
|
75
|
+
if (!existsSync(store)) continue
|
|
76
|
+
const candidates = []
|
|
77
|
+
for (const entry of readdirSync(store)) {
|
|
78
|
+
if (!entry.startsWith('esbuild@')) continue
|
|
79
|
+
candidates.push(join(store, entry, 'node_modules', 'esbuild'))
|
|
80
|
+
}
|
|
81
|
+
if (candidates.length === 0) continue
|
|
82
|
+
const pick = candidates.sort().at(-1)
|
|
83
|
+
try {
|
|
84
|
+
return createRequire(resolve(pick, 'package.json'))(pick)
|
|
85
|
+
} catch { /* store 里没有可用的平台二进制:试下一个根 */ }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
throw new Error(
|
|
89
|
+
'dsh-prompt-customizer: cannot find esbuild.\n'
|
|
90
|
+
+ ' Run `pnpm install` in this directory (esbuild is a devDependency).\n'
|
|
91
|
+
+ ' Or set DSH_CHECKOUT to a DSH checkout to borrow its copy.\n'
|
|
92
|
+
+ ` Checked: ${checkoutCandidates().join(', ')}`,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const esbuild = loadEsbuild()
|
|
97
|
+
|
|
98
|
+
/** Platform packages + react come from the DSH module table at runtime. */
|
|
99
|
+
const CLIENT_EXTERNAL = [
|
|
100
|
+
'react',
|
|
101
|
+
'react/jsx-runtime',
|
|
102
|
+
'react-dom',
|
|
103
|
+
'react-dom/client',
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
/** Browser half: one CJS factory registered with the host module loader. */
|
|
107
|
+
const clientBundle = {
|
|
108
|
+
entryPoints: [resolve(HERE, 'src/client/index.ts')],
|
|
109
|
+
// 浏览器半身落在 client/:package.json 的 exports.\"./client\" 指向它。
|
|
110
|
+
outfile: resolve(HERE, 'client/client.js'),
|
|
111
|
+
bundle: true,
|
|
112
|
+
format: 'cjs',
|
|
113
|
+
platform: 'browser',
|
|
114
|
+
target: ['es2020'],
|
|
115
|
+
jsx: 'automatic',
|
|
116
|
+
sourcemap: true,
|
|
117
|
+
logLevel: 'info',
|
|
118
|
+
external: CLIENT_EXTERNAL,
|
|
119
|
+
// Everything under @deepseek-ai/ stays a runtime require.
|
|
120
|
+
plugins: [{
|
|
121
|
+
name: 'pc-external-platform',
|
|
122
|
+
setup(build) {
|
|
123
|
+
build.onResolve({ filter: /^@deepseek-ai\// }, args => ({ path: args.path, external: true }))
|
|
124
|
+
},
|
|
125
|
+
}],
|
|
126
|
+
banner: {
|
|
127
|
+
js: [
|
|
128
|
+
`window.__ModuleLoader__.load({ id: ${JSON.stringify(PLUGIN_ID)}, factory: (require) => {`,
|
|
129
|
+
'var module = { exports: {} };',
|
|
130
|
+
'var exports = module.exports;',
|
|
131
|
+
'Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });',
|
|
132
|
+
].join('\n'),
|
|
133
|
+
},
|
|
134
|
+
footer: {
|
|
135
|
+
js: 'return module.exports; } });',
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The only bare specifiers the host bundle may leave to runtime resolution.
|
|
141
|
+
*
|
|
142
|
+
* A plugin installed into a profile's node_modules can only resolve packages
|
|
143
|
+
* that DSH actually ships runtime JS for. `@deepseek-ai/cordis` does. Anything
|
|
144
|
+
* else under `@deepseek-ai/` must be vendored into `vendor/` instead — see
|
|
145
|
+
* vendor/README.md.
|
|
146
|
+
*
|
|
147
|
+
* This is an allowlist rather than "whatever resolved at build time" on
|
|
148
|
+
* purpose: the build runs from a source checkout where *every* `@deepseek-ai/*`
|
|
149
|
+
* name resolves to a source dir, so a build-time check would pass and the
|
|
150
|
+
* installed plugin would still crash at startup.
|
|
151
|
+
*/
|
|
152
|
+
const HOST_RUNTIME_EXTERNAL_ALLOWLIST = new Set([
|
|
153
|
+
'@deepseek-ai/cordis',
|
|
154
|
+
])
|
|
155
|
+
|
|
156
|
+
/** Host half: ESM, self-contained except node builtins and the allowlist above. */
|
|
157
|
+
const hostBundle = {
|
|
158
|
+
entryPoints: [resolve(HERE, 'src/host.ts')],
|
|
159
|
+
outfile: resolve(HERE, 'lib/index.js'),
|
|
160
|
+
bundle: true,
|
|
161
|
+
format: 'esm',
|
|
162
|
+
platform: 'node',
|
|
163
|
+
target: 'node22',
|
|
164
|
+
sourcemap: true,
|
|
165
|
+
logLevel: 'info',
|
|
166
|
+
// node:* builtins stay external. js-yaml is bundled in on purpose: the
|
|
167
|
+
// plugin's node_modules is a junction into the DSH profile (so @deepseek-ai/*
|
|
168
|
+
// resolves to the very same module instances the host uses), and a junction
|
|
169
|
+
// cannot also hold a private js-yaml copy.
|
|
170
|
+
//
|
|
171
|
+
// `@deepseek-ai/*` is external *at bundle time* only so esbuild keeps the
|
|
172
|
+
// names verbatim; `assertHostExternals()` then checks every surviving name
|
|
173
|
+
// against the allowlist, so an unvendored dependency fails the build here
|
|
174
|
+
// instead of at plugin startup on someone else's machine.
|
|
175
|
+
external: [],
|
|
176
|
+
// js-yaml is inlined (see above) and is CJS: it calls bare `require('fs')`.
|
|
177
|
+
// In an ESM bundle that throws "Dynamic require ... is not supported", so
|
|
178
|
+
// hand the bundle a real CommonJS require bound to its own URL.
|
|
179
|
+
banner: {
|
|
180
|
+
js: [
|
|
181
|
+
"import { createRequire as __pcCreateRequire } from 'node:module';",
|
|
182
|
+
'const require = __pcCreateRequire(import.meta.url);',
|
|
183
|
+
].join('\n'),
|
|
184
|
+
},
|
|
185
|
+
plugins: [{
|
|
186
|
+
name: 'pc-external-platform',
|
|
187
|
+
setup(build) {
|
|
188
|
+
build.onResolve({ filter: /^(@deepseek-ai\/|node:)/ }, args => ({ path: args.path, external: true }))
|
|
189
|
+
},
|
|
190
|
+
}],
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Fail the build if the host bundle still hands an unresolvable specifier to
|
|
195
|
+
* runtime resolution.
|
|
196
|
+
*
|
|
197
|
+
* Catches exactly the regression that motivated `vendor/`: adding
|
|
198
|
+
* `import { X } from '@deepseek-ai/dsh-llm'` to a host module looks fine on a
|
|
199
|
+
* dev machine (esbuild externalises it, the source checkout resolves it, the
|
|
200
|
+
* smoke test under tsx passes) and then throws ERR_MODULE_NOT_FOUND for every
|
|
201
|
+
* user who installed the plugin.
|
|
202
|
+
*/
|
|
203
|
+
function assertHostExternals(outfile) {
|
|
204
|
+
const source = readFileSync(outfile, 'utf8')
|
|
205
|
+
const specifiers = new Set()
|
|
206
|
+
for (const m of source.matchAll(/(?:^|[;\n])\s*(?:import|export)[\s\S]*?from\s*["']([^"']+)["']/g)) {
|
|
207
|
+
specifiers.add(m[1])
|
|
208
|
+
}
|
|
209
|
+
for (const m of source.matchAll(/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g)) {
|
|
210
|
+
specifiers.add(m[1])
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const violations = [...specifiers].filter((spec) => {
|
|
214
|
+
if (spec.startsWith('node:')) return false
|
|
215
|
+
return !HOST_RUNTIME_EXTERNAL_ALLOWLIST.has(spec)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
if (violations.length > 0) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
'dsh-prompt-customizer: host bundle imports packages that an installed plugin cannot resolve.\n'
|
|
221
|
+
+ violations.map(v => ` - ${v}`).join('\n')
|
|
222
|
+
+ '\n\n'
|
|
223
|
+
+ 'DSH ships @deepseek-ai/* as source only; a plugin inside a profile\'s\n'
|
|
224
|
+
+ 'node_modules gets plain node resolution and finds no lib/index.js.\n'
|
|
225
|
+
+ 'Vendor the leaf modules you need into vendor/ (see vendor/README.md),\n'
|
|
226
|
+
+ `or add the name to HOST_RUNTIME_EXTERNAL_ALLOWLIST in ${basename(fileURLToPath(import.meta.url))}\n`
|
|
227
|
+
+ 'after verifying the package really ships runtime JS.',
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
return [...specifiers]
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await Promise.all([esbuild.build(clientBundle), esbuild.build(hostBundle)])
|
|
234
|
+
const hostExternals = assertHostExternals(resolve(HERE, 'lib/index.js'))
|
|
235
|
+
console.log('[dsh-prompt-customizer] built lib/index.js + client/client.js')
|
|
236
|
+
console.log(`[dsh-prompt-customizer] host runtime imports: ${hostExternals.length === 0 ? '(none)' : hostExternals.join(', ')}`)
|