diff --git a/Dockerfile b/Dockerfile index 1c48381..d34309b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,17 +41,25 @@ RUN npm install -g @deepseek-ai/dsh@${DSH_VERSION} && npm cache clean --force ARG PNPM_VERSION=11.25.0 RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate && pnpm --version -# Official IM plugin, pinned. host-agent copies this seed into each tenant +# Official web plugins, pinned. host-agent copies this seed into each tenant # $DSH_HOME so instances do not download from npm at start. ARG DSH_IM_VERSION=4.8.0 +ARG DSH_AUTOMATION_VERSION=0.1.25 RUN mkdir -p /opt/dsh-seed \ && HOME=/opt/dsh-seed DSH_HOME=/opt/dsh-seed/.dsh \ dsh --profile web --dump-default-config >/dev/null \ && HOME=/opt/dsh-seed DSH_HOME=/opt/dsh-seed/.dsh \ PNPM_STORE_DIR=/tmp/pnpm-store \ - dsh plugin --profile web add -w --save-exact @xmanrui/dsh-im@${DSH_IM_VERSION} \ + dsh plugin --profile web add -w --save-exact --registry=https://registry.npmjs.org/ \ + @xmanrui/dsh-im@${DSH_IM_VERSION} \ + && HOME=/opt/dsh-seed DSH_HOME=/opt/dsh-seed/.dsh \ + PNPM_STORE_DIR=/tmp/pnpm-store \ + dsh plugin --profile web add -w --save-exact --registry=https://registry.npmjs.org/ \ + @michengai/dsh-automation@${DSH_AUTOMATION_VERSION} \ && test -f /opt/dsh-seed/.dsh/profiles/web/node_modules/@xmanrui/dsh-im/package.json \ + && test -f /opt/dsh-seed/.dsh/profiles/web/node_modules/@michengai/dsh-automation/package.json \ && grep -q "@xmanrui/dsh-im" /opt/dsh-seed/.dsh/profiles/web/package.json \ + && grep -q "@michengai/dsh-automation" /opt/dsh-seed/.dsh/profiles/web/package.json \ && rm -rf /tmp/pnpm-store /opt/dsh-seed/.local /opt/dsh-seed/Library \ && chmod -R a+rX /opt/dsh-seed/.dsh diff --git a/README.md b/README.md index 2659d74..63fa13c 100644 --- a/README.md +++ b/README.md @@ -89,10 +89,11 @@ python3 scripts/density.py --cleanup-only --url http://127.0.0.1:9090 镜像只在 GitHub Actions 构建(推 tag `v*.*.*` 触发),不在本地构建。dsh 版本在 Dockerfile 的 `DSH_VERSION` 中锁定;`pnpm` 由 `corepack` 按 `PNPM_VERSION` 钉死并 -放到 PATH(`dsh plugin` 需要它);官方 IM 插件 `@xmanrui/dsh-im` 按 `DSH_IM_VERSION` +放到 PATH(`dsh plugin` 需要它);官方 IM 插件 `@xmanrui/dsh-im` 和定时任务插件 +`@michengai/dsh-automation` 按 `DSH_IM_VERSION` / `DSH_AUTOMATION_VERSION` 预装进 `/opt/dsh-seed/.dsh`,host-agent 拉起实例时写入租户 web profile; `apemind` CLI 同样构建时锁版本 + sha256,装到 `/usr/local/bin/apemind` -(运行期零下载)。升级 dsh、pnpm、dsh-im 或 CLI 一律走新镜像 tag 加回归验证。 +(运行期零下载)。升级 dsh、pnpm、默认插件或 CLI 一律走新镜像 tag 加回归验证。 租户 HOME 与 CLI 身份注入见 [docs/lifecycle.md](docs/lifecycle.md) §1。 当前发布 tag 是 `v0.2.8`。离线机先在联网环境导出镜像再 `docker load`: diff --git a/docs/apemind-integration.md b/docs/apemind-integration.md index 3a8ad0d..7c30793 100644 --- a/docs/apemind-integration.md +++ b/docs/apemind-integration.md @@ -53,8 +53,8 @@ CLI,对 agent 就是「原生能力」——不占上下文预算(不像 MCP 自定义 dsh plugin 则相反:dsh 处于 developer preview,plugin API 随版本漂移, 每次升级锁定版本都要回归;能力面要一个个做成 plugin UI 才有价值,维护成本随 覆盖面线性增长。托管形态从第一天就坚持「零自研 plugin 代码」,本方案维持这个决策。 -IM 渠道用上游 `@xmanrui/dsh-im`(镜像锁版本,不是本仓代码)。将来若确需 -dsh 界面级集成(例如侧栏里的知识库选择器),再单独评估。 +IM 渠道用上游 `@xmanrui/dsh-im`,定时任务用上游 `@michengai/dsh-automation` +(镜像锁版本,不是本仓代码)。将来若确需 dsh 界面级集成(例如侧栏里的知识库选择器),再单独评估。 apemind CLI 现状已经具备关键性质,**不需要重写**:Go 单二进制、零运行时依赖、 `APEMIND_BASE_URL` + `APEMIND_API_KEY` 的 Bearer 认证(env 优先,其次读 diff --git a/docs/architecture.md b/docs/architecture.md index 20a4d94..456d20a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,6 +93,7 @@ flowchart TB - 身份/访问控制 → 网关在 dsh 进程外完成,dsh 零登录。 - 知识库/ApeMind 能力 → 官方 `@deepseek-ai/dsh-mcp-client` 插件(streamable-http + Bearer header),纯配置。 - IM 机器人 → 上游 npm 包 `@xmanrui/dsh-im`,镜像按 `DSH_IM_VERSION` 预装,host-agent 写入每个租户的 web profile;不写自研 plugin。 +- 定时任务 → 上游 npm 包 `@michengai/dsh-automation`,镜像按 `DSH_AUTOMATION_VERSION` 预装,同一条 seed 写入路径。 - 模型接入 → ApeMind 把工作区可用的 chat 模型投影成托管 provider 块(`llm-pi-ai.providers.apemind`,`baseURL` 指 ApeMind 的 OpenAI 兼容网关,Bearer 用托管 key;env 契约与渲染细节见 [lifecycle.md](lifecycle.md) §3.2);BYOK 用户仍可自填官方 provider 配置。 - 托管配置注入 → `dsh web --patch`:官方 patch overlay,managed 配置与用户自己的配置文件互不覆盖。 @@ -167,7 +168,7 @@ sequenceDiagram AIO 底座(Xvfb/Chromium/VNC/noVNC/supervisord/nginx/gem-server/tinyproxy/bubblewrap)整体弃用。托管 dsh WebUI 用不到桌面沙箱,却带来体积、架构限制和多余攻击面。若未来要浏览器自动化/桌面,另起独立镜像轨道。 -全新镜像(node:22-bookworm-slim,amd64+arm64):系统层提供租户 shell 环境与隔离工具;全局安装锁定版本的 `@deepseek-ai/dsh`;`corepack` 钉死 `pnpm` 并放到 PATH(`dsh plugin` 的官方安装器);构建时把锁版本的 `@xmanrui/dsh-im` 装进 `/opt/dsh-seed/.dsh`;构建时锁版本 + sha256 校验装入 `apemind` CLI(`/usr/local/bin/apemind`,运行期零下载);host-agent esbuild 单文件;`tini` 作 PID 1。暴露 8080/9090,数据卷 `/data`。 +全新镜像(node:22-bookworm-slim,amd64+arm64):系统层提供租户 shell 环境与隔离工具;全局安装锁定版本的 `@deepseek-ai/dsh`;`corepack` 钉死 `pnpm` 并放到 PATH(`dsh plugin` 的官方安装器);构建时把锁版本的 `@xmanrui/dsh-im` 和 `@michengai/dsh-automation` 装进 `/opt/dsh-seed/.dsh`;构建时锁版本 + sha256 校验装入 `apemind` CLI(`/usr/local/bin/apemind`,运行期零下载);host-agent esbuild 单文件;`tini` 作 PID 1。暴露 8080/9090,数据卷 `/data`。 - host-agent 以 root 运行(需要 setuid 切租户 uid 与 iptables);容器保持尽可能少的 capability,P2 回环隔离时加 `NET_ADMIN`。 - 私有化扩展点:客户 `FROM apecloud/apemind-computer` 再 apt 加自己的工具链。 diff --git a/docs/lifecycle.md b/docs/lifecycle.md index c34e25b..38aae78 100644 --- a/docs/lifecycle.md +++ b/docs/lifecycle.md @@ -12,7 +12,7 @@ ``` /usr/local/bin/apemind 镜像内置 CLI(构建时锁版本 + sha256),所有实例进程经 PATH 共用 -/opt/dsh-seed/.dsh 镜像内置 web profile seed(含锁版本 @xmanrui/dsh-im),只读,不进 PVC +/opt/dsh-seed/.dsh 镜像内置 web profile seed(含锁版本 @xmanrui/dsh-im 与 @michengai/dsh-automation),只读,不进 PVC /data/users// HOME,0700(防跨租户遍历,与 uid 隔离无关,恒开) workspace/ dsh 进程的 cwd;agent 读写的文件都在这里 .dsh/ DSH_HOME:dsh 自己的会话、缓存、settings @@ -37,7 +37,7 @@ | `/usr/local/bin/apemind` | 镜像构建 | 全租户共用只读二进制,不随 PVC、不随实例删除 | | `.apemind/` | 只有 host-agent(受控制面 ensure 驱动) | **投影**。权威在 ApeMind 数据库(绑定身份的 managed key、MCP 地址、模型清单);磁盘上这份只是启动进程所需的物化,删了可以从控制面重新生成 | | `.dsh/AGENTS.md` | 只有 host-agent | **托管引导**。权威是 `env.json`;每次 spawn 前重写。`.dsh/` 其余文件仍是 dsh 私有 | -| `$DSH_HOME/profiles/web` 里的 `@xmanrui/dsh-im` | host-agent 只补缺 | **默认 IM 插件**。权威是镜像 `/opt/dsh-seed/.dsh`;已有版本和其它插件不覆盖 | +| `$DSH_HOME/profiles/web` 里的默认插件 | host-agent 只补缺 | **默认 IM / 定时任务插件**。权威是镜像 `/opt/dsh-seed/.dsh`;已有版本和其它插件不覆盖 | | `.config/apemind/` | 只有 host-agent | **CLI 凭证投影**。dsh 从 bash/工具子进程剥掉名字含 KEY、PASSWORD、SECRET、TOKEN 的环境变量,agent 跑 `apemind` 时读不到 `APEMIND_API_KEY`;这份 profile 是 CLI 的官方 env 回退。权威仍是 `env.json`,每次 ensure/spawn 覆盖 | | `.dsh/` 其余 | 只有 dsh 进程 | 上游运行时私有。ApeMind 不读它当配置源,也不把它回流主站 | | `workspace/` | 租户(经 dsh agent) | 用户磁盘。闲置回收、host 重启都保留;只在显式删除实例时销毁 | diff --git a/host-agent/src/config.ts b/host-agent/src/config.ts index 9daa719..678d324 100644 --- a/host-agent/src/config.ts +++ b/host-agent/src/config.ts @@ -26,8 +26,8 @@ export interface Config { /** Optional helper that sets PR_SET_PDEATHSIG + a new process group before exec. */ dshExec: string /** - * Seed $DSH_HOME that already has @xmanrui/dsh-im installed. Empty disables - * the default IM plugin. The image writes this under /opt/dsh-seed/.dsh. + * Seed $DSH_HOME that already has the default web plugins installed. Empty + * disables seeding. The image writes this under /opt/dsh-seed/.dsh. */ dshImSeed: string version: string diff --git a/host-agent/src/dsh-im.ts b/host-agent/src/dsh-im.ts index 6e9ff90..3ded2d0 100644 --- a/host-agent/src/dsh-im.ts +++ b/host-agent/src/dsh-im.ts @@ -1,8 +1,9 @@ import * as fsp from "node:fs/promises" import * as path from "node:path" -/** Official npm package installed into every hosted web profile. */ +/** Official npm packages installed into every hosted web profile. */ export const DSH_IM_PACKAGE = "@xmanrui/dsh-im" +export const DSH_AUTOMATION_PACKAGE = "@michengai/dsh-automation" const WEB_REL = path.join("profiles", "web") @@ -33,21 +34,30 @@ export function tenantWebDir(dshHome: string): string { return path.join(dshHome, WEB_REL) } -/** Add the pinned IM plugin without replacing other deps or a user-chosen version. */ -export function mergeDshImBundle( +/** Add every extra plugin from the seed profile. Existing pins stay. */ +export function mergeSeedPlugins( pkg: WebProfilePackage, - version: string, + seedPkg: WebProfilePackage, ): { next: WebProfilePackage; changed: boolean } { const dependencies = { ...(pkg.dependencies ?? {}) } const bundles = [...(pkg.dsh?.profile?.bundles ?? EMPTY_WEB_PROFILE.dsh!.profile!.bundles!)] let changed = false - if (dependencies[DSH_IM_PACKAGE] === undefined) { - dependencies[DSH_IM_PACKAGE] = version - changed = true + for (const [name, version] of Object.entries(seedPkg.dependencies ?? {})) { + if (typeof version !== "string" || version.trim() === "") continue + if (dependencies[name] === undefined) { + dependencies[name] = version + changed = true + } + if (!bundles.includes(name)) { + bundles.push(name) + changed = true + } } - if (!bundles.includes(DSH_IM_PACKAGE)) { - bundles.push(DSH_IM_PACKAGE) - changed = true + for (const name of seedPkg.dsh?.profile?.bundles ?? []) { + if (!bundles.includes(name)) { + bundles.push(name) + changed = true + } } const next: WebProfilePackage = { ...pkg, @@ -63,6 +73,22 @@ export function mergeDshImBundle( return { next, changed } } +/** Add the pinned IM plugin without replacing other deps or a user-chosen version. */ +export function mergeDshImBundle( + pkg: WebProfilePackage, + version: string, +): { next: WebProfilePackage; changed: boolean } { + return mergeSeedPlugins(pkg, { dependencies: { [DSH_IM_PACKAGE]: version } }) +} + +export function seedExtraPins(seedPkg: WebProfilePackage): Array<{ name: string; version: string }> { + const pins: Array<{ name: string; version: string }> = [] + for (const [name, version] of Object.entries(seedPkg.dependencies ?? {})) { + if (typeof version === "string" && version.trim() !== "") pins.push({ name, version }) + } + return pins +} + export function mergeWorkspaceExclude(text: string, spec: string): string { const quoted = `'${spec}'` if (text.includes(quoted) || text.includes(spec)) return text @@ -126,7 +152,7 @@ export interface EnsureDshImResult { } /** - * Make sure a tenant web profile lists and can load the baked IM plugin. + * Make sure a tenant web profile lists and can load every baked default plugin. * Missing seed (dev / unit tests) is a no-op. Existing extra plugins stay. */ export async function ensureDefaultDshIm(dshHome: string, seedDshHome: string): Promise { @@ -140,9 +166,9 @@ export async function ensureDefaultDshIm(dshHome: string, seedDshHome: string): } catch { return { applied: false, reason: "seed package unreadable" } } - const version = seedPkg.dependencies?.[DSH_IM_PACKAGE] - if (typeof version !== "string" || version.trim() === "") { - return { applied: false, reason: "seed has no dsh-im pin" } + const pins = seedExtraPins(seedPkg) + if (pins.length === 0) { + return { applied: false, reason: "seed has no extra plugins" } } const web = tenantWebDir(dshHome) @@ -156,7 +182,7 @@ export async function ensureDefaultDshIm(dshHome: string, seedDshHome: string): pkg = EMPTY_WEB_PROFILE } } - const merged = mergeDshImBundle(pkg, version) + const merged = mergeSeedPlugins(pkg, seedPkg) let changed = merged.changed || !(await pathExists(pkgPath)) if (changed) { await fsp.writeFile(pkgPath, `${JSON.stringify(merged.next, null, 2)}\n`, { mode: 0o644 }) @@ -165,17 +191,15 @@ export async function ensureDefaultDshIm(dshHome: string, seedDshHome: string): const seedWs = path.join(seedWebDir(seed), "pnpm-workspace.yaml") const tenantWs = path.join(web, "pnpm-workspace.yaml") if (await pathExists(seedWs)) { - const spec = `${DSH_IM_PACKAGE}@${version}` - if (await pathExists(tenantWs)) { - const current = await fsp.readFile(tenantWs, "utf8") - const next = mergeWorkspaceExclude(current, spec) - if (next !== current) { - await fsp.writeFile(tenantWs, next.endsWith("\n") ? next : `${next}\n`, { mode: 0o644 }) - changed = true - } - } else { - const seedText = await fsp.readFile(seedWs, "utf8") - await fsp.writeFile(tenantWs, mergeWorkspaceExclude(seedText, spec), { mode: 0o644 }) + let workspace = (await pathExists(tenantWs)) + ? await fsp.readFile(tenantWs, "utf8") + : await fsp.readFile(seedWs, "utf8") + const before = workspace + for (const pin of pins) { + workspace = mergeWorkspaceExclude(workspace, `${pin.name}@${pin.version}`) + } + if (workspace !== before || !(await pathExists(tenantWs))) { + await fsp.writeFile(tenantWs, workspace.endsWith("\n") ? workspace : `${workspace}\n`, { mode: 0o644 }) changed = true } } diff --git a/host-agent/src/supervisor.ts b/host-agent/src/supervisor.ts index e317344..de0c79b 100644 --- a/host-agent/src/supervisor.ts +++ b/host-agent/src/supervisor.ts @@ -513,7 +513,7 @@ export class Supervisor { await this.syncManagedFiles(inst, extraEnv) const dshHome = path.join(home, ".dsh") const seeded = await ensureDefaultDshIm(dshHome, this.cfg.dshImSeed) - if (seeded.applied) log.info("default dsh-im seeded", { user: inst.userId }) + if (seeded.applied) log.info("default plugins seeded", { user: inst.userId }) if (inst.meta.uid !== undefined) { await chownTree(path.join(this.homeDir(inst.userId), ".apemind"), inst.meta.uid, inst.meta.uid) if (seeded.applied) await chownTree(path.join(dshHome, "profiles"), inst.meta.uid, inst.meta.uid) diff --git a/host-agent/test/dsh-im.test.ts b/host-agent/test/dsh-im.test.ts index 54c4a07..fbc0359 100644 --- a/host-agent/test/dsh-im.test.ts +++ b/host-agent/test/dsh-im.test.ts @@ -5,10 +5,12 @@ import * as os from "node:os" import * as path from "node:path" import { test } from "node:test" import { + DSH_AUTOMATION_PACKAGE, DSH_IM_PACKAGE, EMPTY_WEB_PROFILE, ensureDefaultDshIm, mergeDshImBundle, + mergeSeedPlugins, mergeWorkspaceExclude, } from "../src/dsh-im.ts" @@ -32,6 +34,25 @@ test("merge adds the pin and bundle without dropping other plugins", () => { ]) }) +test("mergeSeedPlugins adds every seed extra without dropping others", () => { + const { next, changed } = mergeSeedPlugins( + { + ...EMPTY_WEB_PROFILE, + dependencies: { leftover: "1.0.0" }, + dsh: { profile: { bundles: ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"] } }, + }, + { + dependencies: { [DSH_IM_PACKAGE]: "4.8.0", [DSH_AUTOMATION_PACKAGE]: "0.1.25" }, + }, + ) + assert.equal(changed, true) + assert.equal(next.dependencies?.[DSH_IM_PACKAGE], "4.8.0") + assert.equal(next.dependencies?.[DSH_AUTOMATION_PACKAGE], "0.1.25") + assert.equal(next.dependencies?.leftover, "1.0.0") + assert.ok(next.dsh?.profile?.bundles?.includes(DSH_IM_PACKAGE)) + assert.ok(next.dsh?.profile?.bundles?.includes(DSH_AUTOMATION_PACKAGE)) +}) + test("merge keeps a user-chosen dsh-im version", () => { const { next, changed } = mergeDshImBundle( { @@ -69,12 +90,17 @@ test("ensure writes the profile and copies only missing packages", async () => { const seedWeb = path.join(dir, "seed", "profiles", "web") const seedNm = path.join(seedWeb, "node_modules") await fsp.mkdir(path.join(seedNm, "@xmanrui", "dsh-im"), { recursive: true }) + await fsp.mkdir(path.join(seedNm, "@michengai", "dsh-automation"), { recursive: true }) await fsp.mkdir(path.join(seedNm, "undici"), { recursive: true }) await fsp.writeFile(path.join(seedNm, "@xmanrui", "dsh-im", "index.js"), "export default 1\n") + await fsp.writeFile(path.join(seedNm, "@michengai", "dsh-automation", "index.js"), "export default 3\n") await fsp.writeFile(path.join(seedNm, "undici", "index.js"), "export default 2\n") await fsp.writeFile( path.join(seedWeb, "package.json"), - `${JSON.stringify({ name: "dsh-profile-web", dependencies: { [DSH_IM_PACKAGE]: "4.8.0" } }, null, 2)}\n`, + `${JSON.stringify({ + name: "dsh-profile-web", + dependencies: { [DSH_IM_PACKAGE]: "4.8.0", [DSH_AUTOMATION_PACKAGE]: "0.1.25" }, + }, null, 2)}\n`, ) await fsp.writeFile( path.join(seedWeb, "pnpm-workspace.yaml"), @@ -98,11 +124,19 @@ test("ensure writes the profile and copies only missing packages", async () => { assert.equal(result.applied, true) const pkg = JSON.parse(fs.readFileSync(path.join(tenant, "profiles", "web", "package.json"), "utf8")) assert.equal(pkg.dependencies[DSH_IM_PACKAGE], "4.8.0") + assert.equal(pkg.dependencies[DSH_AUTOMATION_PACKAGE], "0.1.25") assert.equal(pkg.dependencies.leftover, "1.0.0") assert.ok(pkg.dsh.profile.bundles.includes(DSH_IM_PACKAGE)) + assert.ok(pkg.dsh.profile.bundles.includes(DSH_AUTOMATION_PACKAGE)) assert.equal(fs.readFileSync(path.join(tenantNm, "undici", "index.js"), "utf8"), "keep-me\n") assert.equal(fs.readFileSync(path.join(tenantNm, "@xmanrui", "dsh-im", "index.js"), "utf8"), "export default 1\n") - assert.match(fs.readFileSync(path.join(tenant, "profiles", "web", "pnpm-workspace.yaml"), "utf8"), /dsh-im@4\.8\.0/) + assert.equal( + fs.readFileSync(path.join(tenantNm, "@michengai", "dsh-automation", "index.js"), "utf8"), + "export default 3\n", + ) + const workspace = fs.readFileSync(path.join(tenant, "profiles", "web", "pnpm-workspace.yaml"), "utf8") + assert.match(workspace, /dsh-im@4\.8\.0/) + assert.match(workspace, /dsh-automation@0\.1\.25/) } finally { await fsp.rm(dir, { recursive: true, force: true }) } diff --git a/host-agent/test/supervisor.test.ts b/host-agent/test/supervisor.test.ts index 03b624c..ecf120c 100644 --- a/host-agent/test/supervisor.test.ts +++ b/host-agent/test/supervisor.test.ts @@ -316,15 +316,20 @@ test("state survives a supervisor restart via meta.json", async () => { } }) -test("start copies the baked dsh-im plugin into the tenant web profile", async () => { +test("start copies the baked default plugins into the tenant web profile", async () => { const seed = fs.mkdtempSync(path.join(os.tmpdir(), "dsh-im-host-seed-")) try { const seedWeb = path.join(seed, "profiles", "web") fs.mkdirSync(path.join(seedWeb, "node_modules", "@xmanrui", "dsh-im"), { recursive: true }) + fs.mkdirSync(path.join(seedWeb, "node_modules", "@michengai", "dsh-automation"), { recursive: true }) fs.writeFileSync(path.join(seedWeb, "node_modules", "@xmanrui", "dsh-im", "index.js"), "ok\n") + fs.writeFileSync(path.join(seedWeb, "node_modules", "@michengai", "dsh-automation", "index.js"), "auto\n") fs.writeFileSync( path.join(seedWeb, "package.json"), - `${JSON.stringify({ name: "dsh-profile-web", dependencies: { "@xmanrui/dsh-im": "4.8.0" } }, null, 2)}\n`, + `${JSON.stringify({ + name: "dsh-profile-web", + dependencies: { "@xmanrui/dsh-im": "4.8.0", "@michengai/dsh-automation": "0.1.25" }, + }, null, 2)}\n`, ) const env = await makeEnv({ COMPUTER_DSH_IM_SEED: seed }) try { @@ -332,14 +337,12 @@ test("start copies the baked dsh-im plugin into the tenant web profile", async ( const pkgPath = path.join(env.cfg.dataDir, "users", "mina", ".dsh", "profiles", "web", "package.json") const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) assert.equal(pkg.dependencies["@xmanrui/dsh-im"], "4.8.0") + assert.equal(pkg.dependencies["@michengai/dsh-automation"], "0.1.25") assert.ok(pkg.dsh.profile.bundles.includes("@xmanrui/dsh-im")) - assert.equal( - fs.readFileSync( - path.join(env.cfg.dataDir, "users", "mina", ".dsh", "profiles", "web", "node_modules", "@xmanrui", "dsh-im", "index.js"), - "utf8", - ), - "ok\n", - ) + assert.ok(pkg.dsh.profile.bundles.includes("@michengai/dsh-automation")) + const webNm = path.join(env.cfg.dataDir, "users", "mina", ".dsh", "profiles", "web", "node_modules") + assert.equal(fs.readFileSync(path.join(webNm, "@xmanrui", "dsh-im", "index.js"), "utf8"), "ok\n") + assert.equal(fs.readFileSync(path.join(webNm, "@michengai", "dsh-automation", "index.js"), "utf8"), "auto\n") } finally { await env.cleanup() }