支持线从 ^0.1.7-rc.1 整体前移到 ^0.2.0-rc.1;0.1.7 线(含 npm latest 的 0.1.7-rc.2)停留在 v0.23.0。适配前实测确认这是纯增量,故本版无运行时兼容分支。 必改项(否则装不上/不加载) - 14 条 DSH peer → ^0.2.0-rc.1。实测 caret 跨 minor 不成立:`^0.1.7-rc.1` 展开为 >=0.1.7-rc.1 <0.2.0-0,对 0.2.0-rc.1 判定 false(includePrerelease 也救不了)。判别性验证:把 peer 回退成 ^0.1.7-rc.1 后,在 0.2.0-rc.1 宿主上 `dsh plugin add` 直接拒绝并回滚包树—— "installation rejected: Plugin dsh-better-sidebar@0.24.0 is incompatible with dsh 0.2.0-rc.1: peerDependencies {…^0.1.7-rc.1…}";恢复 ^0.2.0-rc.1 后安装通过 - 28 个 @deepseek-ai/* devDependencies 精确钉 0.2.0-rc.1(cordis 4.0.4 / cordis-plugin-group 1.0.4 / cordis-plugin-include 1.0.9 不变) - 新增 @deepseek-ai/dsh-util-code-language@0.2.0-rc.1:0.2.0 的 ui-primitives 产物裸 import 它(0.1.7 的产物同样 import,只是旧树里恰好被传递提供), 不补则所有加载 ui-primitives 的 20 个 spec 在收集期失败 实测证据(写进 docs/plans/2026-09-28-dsh-0.2.0-rc.1-adaptation.md) - 本插件消费的 19 个宿主包零值导出删除(对比 export {} / exports.x=) - 类型面仅 3 处且全为增量:ui-primitives(DisclosureRow/TextShimmer/Tooltip 新增可选 prop、overlay 顶部内距)、dsh-session(新增 ToolCallRecovery)、 dsh-api-remotes(新增 product-analytics remote) - 会话格式仍 v4、SUBAGENT_DESCRIPTOR_VERSION 仍 3、dsh/lib/bin.js 与 dsh-client-modules 运行时逐字节相同、web profile 客户端 inject 列表相同、 ui-primitives 裸 import 集合不变、agent-team 四包类型逐字相同 - 宿主 web profile:新增 desktop-only product-analytics/telemetry(web 上 disabled);time-context/schedule/ui-schedule(本就 disabled)移入可选 dsh-experimental-schedule-bundle;插件不依赖宿主 schedule 其余同步 - CI 与挂载 lane 钉 @deepseek-ai/dsh@0.2.0-rc.1(scripts/e2e-common.sh 的 DSH_NPX_SPEC/DSH_EXPECT_VERSION、ci.yml),注释记下「0.2.0 首个候选版只挂 next」与「caret 跨 minor 必失效」两条教训 - tests/market-manifest.spec.ts 的 peer 形状断言钉当前基线 tuple - dsh.plugin.json engines.dsh → >=0.2.0-rc.1;SIDEBAR_SERVICE_VERSION → 0.24.0 - README/README_EN、CHANGELOG/CHANGELOG_EN、AGENTS.md(§2 钉版句 + §3 改为 「DSH 版本适配要点」并新增 §3.0 当前基线,0.1.7 内容降为历史分节)、 docs/external-plugin-guide.md 全部同步 验证:pnpm peers check 仅剩上游 better-locale 的 5 条 unmet(非本插件 peer); typecheck / lint / test(136 文件 1484 通过 9 跳过)/ build 全绿; DSH_CMD='npx -y --package @deepseek-ai/dsh@0.2.0-rc.1 dsh' pnpm test:mount = 7/7; pnpm test:mount:aggregate 通过(聚合双挂载退让)
148 lines
7.5 KiB
TypeScript
148 lines
7.5 KiB
TypeScript
/**
|
|
* DSH community-market compatibility guard: keeps the npm package inside the
|
|
* "Host 接受什么" rules of dsh-community-market's
|
|
* docs/install-and-uninstall.zh.md, so the catalog's verified npm install
|
|
* target (repository_backlink) keeps passing for this package:
|
|
*
|
|
* - no `cordis` entry in dependencies / peerDependencies /
|
|
* optionalDependencies (the market preview hard-rejects the legacy Cordis
|
|
* runtime name in any of the three fields, even when optional),
|
|
* - no install lifecycle scripts (preinstall / install / postinstall /
|
|
* prepare) in the **packed** manifest — the repo manifest legitimately
|
|
* keeps `prepare` for git-install builds; pnpm strips it from the published
|
|
* surface, and this guard verifies that stripped surface stays clean,
|
|
* - a safe, bund-visible `dsh.bundle.patch` path (`./cordis.patch.yml`),
|
|
* - a repository identity that normalizes to a credential-free HTTPS GitHub
|
|
* owner/repo URL (the catalog ↔ npm backlink comparison),
|
|
* - an exact SemVer version (no ranges, no tags; a prerelease suffix is
|
|
* allowed — prerelease releases ride the npm `alpha` dist-tag and are not
|
|
* the market's verified `latest` install target until a stable cut).
|
|
*
|
|
* The packed-manifest checks run `pnpm pack` into a temp dir (the `prepare`
|
|
* script rebuilds the gitignored lib/, like the release flow does).
|
|
*/
|
|
import { execFileSync } from 'node:child_process'
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
|
|
|
const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')) as {
|
|
name: string
|
|
version: string
|
|
repository?: { type?: string; url?: string }
|
|
engines?: { node?: string }
|
|
dsh?: { bundle?: { patch?: string } }
|
|
scripts?: Record<string, string>
|
|
dependencies?: Record<string, string>
|
|
peerDependencies?: Record<string, string>
|
|
optionalDependencies?: Record<string, string>
|
|
}
|
|
|
|
/** The market's lifecycle-script reject list (install-and-uninstall.zh.md). */
|
|
const LIFECYCLE_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepare'] as const
|
|
|
|
/** Exact SemVer: exactly three numeric segments, optional prerelease suffix
|
|
* (no range / tag — prerelease cuts publish under the npm `alpha` dist-tag). */
|
|
const EXACT_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u
|
|
|
|
/** Read `package.json` from a fresh `pnpm pack` tarball (the publish surface). */
|
|
function packedManifest(): Record<string, unknown> {
|
|
const dir = mkdtempSync(join(tmpdir(), 'market-pack-'))
|
|
try {
|
|
// On Windows `pnpm` ships as a `.cmd` shim that `execFileSync` cannot
|
|
// spawn directly (ENOENT). Run it through the shell so the same call
|
|
// works on every platform; `tar` is available on all supported OSes.
|
|
execFileSync('pnpm', ['pack', '--pack-destination', dir], { cwd: ROOT, stdio: 'pipe', shell: process.platform === 'win32' })
|
|
// Never hand tar an absolute Windows path: Git Bash's GNU tar reads the
|
|
// drive-letter colon as a remote-host spec ("Cannot connect to C:").
|
|
// Run it from the pack dir with the bare tarball name instead.
|
|
execFileSync('tar', ['-xzf', `${pkg.name}-${pkg.version}.tgz`, 'package/package.json'], { cwd: dir, stdio: 'pipe' })
|
|
return JSON.parse(readFileSync(join(dir, 'package/package.json'), 'utf8')) as Record<string, unknown>
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
describe('DSH community-market manifest compatibility', () => {
|
|
it('declares no cordis entry in any dependency field (dependencies / peerDependencies / optionalDependencies)', () => {
|
|
for (const field of ['dependencies', 'peerDependencies', 'optionalDependencies'] as const) {
|
|
const deps = pkg[field]
|
|
if (deps === undefined) continue
|
|
expect(Object.keys(deps), field).not.toContain('cordis')
|
|
}
|
|
})
|
|
|
|
it('declares no install lifecycle scripts in the PACKED manifest (the published surface)', () => {
|
|
const packed = packedManifest()
|
|
const scripts = (packed.scripts as Record<string, string> | undefined) ?? {}
|
|
for (const script of LIFECYCLE_SCRIPTS) {
|
|
expect(scripts, script).not.toHaveProperty(script)
|
|
}
|
|
}, 180000)
|
|
|
|
it('declares a safe bundle patch that exists inside the package', () => {
|
|
const patch = pkg.dsh?.bundle?.patch
|
|
expect(patch, 'dsh.bundle.patch').toBeDefined()
|
|
const path = patch!
|
|
expect(path.startsWith('./'), 'must be a relative path').toBe(true)
|
|
const relative = path.slice(2)
|
|
expect(relative).not.toBe('')
|
|
expect(relative.startsWith('/')).toBe(false)
|
|
expect(relative.includes('\\')).toBe(false)
|
|
expect(relative.split('/').every(segment => segment.length > 0 && segment !== '.' && segment !== '..'))
|
|
.toBe(true)
|
|
expect(existsSync(resolve(ROOT, relative)), relative).toBe(true)
|
|
})
|
|
|
|
it('declares a repository identity the market can normalize to a credential-free HTTPS GitHub owner/repo URL', () => {
|
|
const repository = pkg.repository
|
|
expect(repository, 'repository').toBeDefined()
|
|
const url = new URL(repository!.url!)
|
|
expect(url.protocol).toBe('https:')
|
|
expect(url.username).toBe('')
|
|
expect(url.password).toBe('')
|
|
expect(url.search).toBe('')
|
|
expect(url.hash).toBe('')
|
|
expect(url.hostname.toLowerCase()).toBe('github.com')
|
|
expect(url.pathname.split('/').filter(Boolean)).toHaveLength(2)
|
|
})
|
|
|
|
it('declares an exact SemVer version (no range or tag; prerelease allowed for the alpha track)', () => {
|
|
expect(pkg.version).toMatch(EXACT_SEMVER)
|
|
})
|
|
|
|
it('keeps every DSH peer a floating range the host runtime can satisfy', () => {
|
|
// DSH 0.1.7-rc.1 added a startup compatibility preflight
|
|
// (packages/boot/app-boot/src/plugin-compatibility.ts): for every peer
|
|
// named `@deepseek-ai/dsh` or `@deepseek-ai/dsh-*`, the host evaluates
|
|
// `semver.satisfies(runtimeVersion, range, { includePrerelease: true })`
|
|
// and DISABLES the whole profile row when it fails — one stderr line in a
|
|
// log nobody reads, no dialog, and `peerDependenciesMeta.optional` grants
|
|
// no exemption. An exact pin (the style devDependencies use) or a
|
|
// comparator carrying no prerelease (`^0.2.0`) would therefore make the
|
|
// plugin silently stop loading, so the published ranges must stay carets
|
|
// on the baseline tuple. `pnpm peers check` cannot see this: it validates
|
|
// the resolved dev tree, not the range shape a host will read.
|
|
//
|
|
// A caret NEVER spans a minor bump, even with `includePrerelease` on:
|
|
// `^0.1.7-rc.1` expands to `>=0.1.7-rc.1 <0.2.0-0`, so it rejects
|
|
// `0.2.0-rc.1` (measured: false) and a 0.2.0 host would disable the row
|
|
// instead of loading the plugin. The support line therefore moves WITH the
|
|
// baseline and is never widened to "cover both lines". (An explicit
|
|
// comparator behaves differently — `>=0.1.1-rc.2 <0.2.0` does admit
|
|
// `0.2.0-rc.1`, because only carets get the `-0` upper bound — which is why
|
|
// the assertion below pins the baseline tuple instead of accepting any
|
|
// caret that looks plausible.)
|
|
const peers = pkg.peerDependencies ?? {}
|
|
const dshPeers = Object.keys(peers).filter(name => name === '@deepseek-ai/dsh' || name.startsWith('@deepseek-ai/dsh-'))
|
|
expect(dshPeers.length).toBeGreaterThan(10)
|
|
for (const name of dshPeers) {
|
|
expect(peers[name], name).toMatch(/^\^0\.2\.0-[a-z0-9.]+$/)
|
|
}
|
|
})
|
|
})
|