diff --git a/.freebuff/project-id b/.freebuff/project-id deleted file mode 100644 index e69de29..0000000 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d79d54f..3c86163 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,7 +129,7 @@ jobs: export NODE_AUTH_TOKEN=${{ secrets.GITHUB_TOKEN }} PUBLISH_REGISTRY=https://npm.pkg.github.com node --experimental-strip-types scripts/publish-scoped.ts # The release claim, checked instead of assumed. Without this the job - # reported success while `@viztor/dsh-opencode-patch` sat four versions behind + # reported success while `@viztor/dsh-opencode*` sat four versions behind # the canonical package, because a publisher that was never registered # fails with the same E404 as a transient fault. - name: verify every published target @@ -137,7 +137,7 @@ jobs: VER=$(node -p "require('./package.json').version") # One line: scripts/check.ts reads it to prove this list equals what # publish-scoped.ts publishes (scripts/scoped-targets.ts). - pending="dsh-opencode-patch@$VER @viztor/dsh-opencode-patch@$VER" + pending="dsh-opencode-patch@$VER @viztor/dsh-opencode-patch@$VER @viztor/dsh-opencode@$VER" # Polls, because a successful `npm publish` does not mean the version # is readable yet: npm answers "Your package is being processed and diff --git a/README.md b/README.md index dc9aabf..d6fce66 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ cd ~/.dsh/profiles/web npm install dsh-opencode-patch ``` -The package is `dsh-opencode-patch` under every name it is used by: the dependency you install, the bundle entry in your profile, and the row the host resolves. It is also published as [`@viztor/dsh-opencode-patch`](https://www.npmjs.com/package/@viztor/dsh-opencode-patch); the retired name `@viztor/dsh-opencode` is no longer published, so an install still on it should move to either of those. +The package is `dsh-opencode-patch` under every name it is used by: the dependency you install, the bundle entry in your profile, and the row the host resolves. It is also published as [`@viztor/dsh-opencode-patch`](https://www.npmjs.com/package/@viztor/dsh-opencode-patch), and the older name [`@viztor/dsh-opencode`](https://www.npmjs.com/package/@viztor/dsh-opencode) keeps publishing as a deprecated thin wrapper that re-exports it — an install still on that name keeps working, and moving to either of the other two is a rename rather than a fix. **2. Enable the bundle** — add the package to the profile's `dsh.profile.bundles` array: @@ -429,7 +429,7 @@ These exist in the schema but render no control — each is a literal, a marker, - **A smaller public API.** The package's entry exports `apply`, `Config`, `inject` and `name` — the names the host reads — and the config types (`PluginConfig`, `ResolvedPluginConfig`, `ModelOverrideConfig`, `ModelRouteConfig`, `EndpointFamily`, `KeySourcePolicy`). Everything else it used to re-export is internal now. - **`./src/*` is no longer an export.** `src/` was never in the published files, so the subpath resolved in the repository and nowhere else. - **`internalRoutes` was removed.** The per-protocol routes always mount. A profile row that still carries `internalRoutes` validates and the key is ignored. -- **`@viztor/dsh-opencode` is no longer published.** Its last version's "Renamed to @viztor/dsh-opencode-patch" deprecation stands as the final message; install `dsh-opencode-patch` or `@viztor/dsh-opencode-patch` instead. +- **`@viztor/dsh-opencode` is deprecated, and still published.** It is a thin wrapper that re-exports `dsh-opencode-patch`, so an install on the old name keeps resolving; the registry marks it deprecated and points at the canonical name. New installs should use `dsh-opencode-patch` or `@viztor/dsh-opencode-patch`. ## 📜 Compatibility & Verification diff --git a/README.zh-CN.md b/README.zh-CN.md index 8f4dfad..3d68af8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -84,7 +84,7 @@ cd ~/.dsh/profiles/web npm install dsh-opencode-patch ``` -包名在所有用到它的地方都是 `dsh-opencode-patch`:你安装的依赖、profile 里的 bundle 条目,以及宿主解析的那一行。它也以 [`@viztor/dsh-opencode-patch`](https://www.npmjs.com/package/@viztor/dsh-opencode-patch) 发布;旧名 `@viztor/dsh-opencode` 已不再发布,仍在使用它的安装请改用上述任一名称。 +包名在所有用到它的地方都是 `dsh-opencode-patch`:你安装的依赖、profile 里的 bundle 条目,以及宿主解析的那一行。它也以 [`@viztor/dsh-opencode-patch`](https://www.npmjs.com/package/@viztor/dsh-opencode-patch) 发布,而旧名 [`@viztor/dsh-opencode`](https://www.npmjs.com/package/@viztor/dsh-opencode) 继续以**已弃用的薄包装**发布、转导出本包——仍在使用旧名的安装照常可用,换到另外两个名字只是改名,不是修问题。 **2. 启用 bundle**——把该包加入 profile 的 `dsh.profile.bundles` 数组: @@ -423,7 +423,7 @@ Zen 路线上不出现任何 Go 的数字:根本不画圆环(没有可量的 - **更小的公开 API。** 包入口只导出 `apply`、`Config`、`inject` 和 `name`(宿主读取的名称),以及配置类型(`PluginConfig`、`ResolvedPluginConfig`、`ModelOverrideConfig`、`ModelRouteConfig`、`EndpointFamily`、`KeySourcePolicy`)。此前额外重新导出的其余内容现在都是内部实现。 - **`./src/*` 不再是导出路径。** `src/` 从未包含在发布文件中,所以这个子路径只在仓库内可解析,安装后并不存在。 - **`internalRoutes` 已移除。** 按协议划分的内部路由总是会挂载。仍带有 `internalRoutes` 的 profile 配置行可以通过校验,该键会被忽略。 -- **`@viztor/dsh-opencode` 不再发布。** 其最后一个版本上的 "Renamed to @viztor/dsh-opencode-patch" 弃用提示即为最终信息;请改装 `dsh-opencode-patch` 或 `@viztor/dsh-opencode-patch`。 +- **`@viztor/dsh-opencode` 已弃用,但仍在发布。** 它是一个转导出 `dsh-opencode-patch` 的薄包装,所以用旧名的安装照常解析;registry 上标记为弃用并指向正式名称。新安装请用 `dsh-opencode-patch` 或 `@viztor/dsh-opencode-patch`。 ## 📜 兼容性与验证 diff --git a/scripts/publish-scoped.ts b/scripts/publish-scoped.ts index 6a3a016..c392f66 100644 --- a/scripts/publish-scoped.ts +++ b/scripts/publish-scoped.ts @@ -4,16 +4,21 @@ * The package ships as `dsh-opencode-patch` — the unscoped name DSH resolves, * the name the docs use, the name release-please versions. * - * It also publishes under `@viztor/dsh-opencode-patch` — the CANONICAL scoped - * name. Same tree, same bundle, same version as the unscoped one; this is the - * name new installs should use, and the one the client bundle registers its - * card under (`SCOPED_PKG`). The list is `SCOPED_TARGETS` - * (`scripts/scoped-targets.ts`), which `scripts/check.ts` holds in step with - * release.yml's verify step. + * It also publishes under: + * - `@viztor/dsh-opencode-patch` — the CANONICAL scoped name. Same tree, same + * bundle, same version as the unscoped one; this is the name new installs + * should use, and the one the client bundle registers its card under + * (`SCOPED_PKG`). + * - `@viztor/dsh-opencode` — DEPRECATED, and still published. A thin wrapper + * that ships no `lib/client.js` and forwards its row to `dsh-opencode-patch`. + * It is kept publishing so an existing install keeps resolving — a name that + * stops publishing breaks the installs that still use it, which is the one + * thing a compatibility alias must not do. Nothing in the source tree + * registers or advertises it, which is what stops one package answering to + * two spellings. * - * The retired name `@viztor/dsh-opencode` is no longer published: its last - * version's "Renamed to @viztor/dsh-opencode-patch" deprecation stands as the - * final message on the registry. + * The list is `SCOPED_TARGETS` (`scripts/scoped-targets.ts`), which + * `scripts/check.ts` holds in step with release.yml's verify step. * * Usage: node --experimental-strip-types scripts/publish-scoped.ts * PUBLISH_DRY_RUN=1 node --experimental-strip-types scripts/publish-scoped.ts @@ -37,6 +42,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { SCOPED_TARGETS } from "./scoped-targets.ts"; +import { wrapperForwarder } from "./wrapper-forwarder.ts"; const ROOT = path.resolve(import.meta.dirname, ".."); @@ -119,6 +125,79 @@ for (const target of SCOPED_TARGETS) { 'name: "@viztor/dsh-opencode-patch"' ) ); + } else if (target === "@viztor/dsh-opencode") { + // Point at the CANONICAL name, not the other alias: the message a user + // reads on the registry is the one place that should not offer a choice + // between two spellings of the same package. + manifest.deprecated = + "Renamed to @viztor/dsh-opencode-patch. Please install that instead: https://www.npmjs.com/package/@viztor/dsh-opencode-patch"; + const existingDeps = + (manifest.dependencies as Record | undefined) ?? {}; + manifest.dependencies = { + ...existingDeps, + "dsh-opencode-patch": `^${version}`, + }; + + // Thin wrapper entrypoints that re-export dsh-opencode-patch directly. + // + // **The names the canonical package HAS, and no `default`:** it exports + // none, and re-exporting one is a link error on every load — which is + // what this used to ship. The text comes from one helper so the runtime + // entry and the type declaration cannot drift. + const forwarder = wrapperForwarder(); + writeFileSync(path.join(scratch, "lib", "index.mjs"), `${forwarder}\n`); + writeFileSync(path.join(scratch, "lib", "index.d.mts"), `${forwarder}\n`); + + // The wrapper carries no client half of its own. + // + // Its patch forwards to the `dsh-opencode-patch` row, and that row's own + // package supplies the browser bundle. Shipping a second copy here — the + // manifest's whole-`lib` `files` entry copies it in — would hand the page + // the same bundle twice under the same module ids, mounting the client + // half twice. A ~38 kB duplicate in a package whose stated job is to + // forward two lines is also simply not thin. + const dsh = manifest.dsh as Record | undefined; + if (dsh !== undefined) delete dsh.client; + const wrapperExports = manifest.exports as + | Record + | undefined; + if (wrapperExports !== undefined) delete wrapperExports["./client"]; + rmSync(path.join(scratch, "lib", "client.js"), { force: true }); + + // Forwarding cordis patch to mount dsh-opencode-patch + const forwarderPatch = [ + "# Thin wrapper patch forwarding to dsh-opencode-patch", + "- insert:", + " - id: dsh-opencode-patch", + ' name: "dsh-opencode-patch"', + ].join("\n"); + writeFileSync( + path.join(scratch, "cordis.patch.yml"), + `${forwarderPatch}\n` + ); + + // The page leads with the CANONICAL name. A retired name may appear as the + // reason the reader is here — never as the package's own title, which is + // what made three npm pages look like three packages. + const redirectReadme = [ + "# dsh-opencode-patch", + "", + "> ⚠️ **This npm name is deprecated.** The package is `dsh-opencode-patch`,", + "> scoped as [`@viztor/dsh-opencode-patch`](https://www.npmjs.com/package/@viztor/dsh-opencode-patch).", + "", + "You are on the page for the old name `@viztor/dsh-opencode`. It is a **thin compatibility", + "wrapper**: it depends on and re-exports `dsh-opencode-patch` and ships no code of its own.", + "", + "### How to migrate", + "", + "```sh", + '# DSH Web UI (recommended): Settings → Plugins → Install Plugin → "dsh-opencode-patch"', + "", + "# Or from your profile directory:", + "npm install dsh-opencode-patch", + "```", + ].join("\n"); + writeFileSync(path.join(scratch, "README.md"), redirectReadme); } writeFileSync( path.join(scratch, "package.json"), diff --git a/scripts/scoped-targets.ts b/scripts/scoped-targets.ts index 484c730..f1639af 100644 --- a/scripts/scoped-targets.ts +++ b/scripts/scoped-targets.ts @@ -6,10 +6,14 @@ * from `publish-scoped.ts` because that script publishes at import time, so the * check and its test could not read the list from there. * - * `@viztor/dsh-opencode` is no longer published (retired in 1.x, D9): its last - * version's "Renamed to @viztor/dsh-opencode-patch" deprecation stands as the - * final message on the registry. + * `@viztor/dsh-opencode` is DEPRECATED and still published: a thin wrapper that + * re-exports the canonical package, kept so an existing install keeps resolving. + * A compatibility name that stops publishing breaks the installs still using it, + * which is the one thing it must not do. * * @module scripts/scoped-targets */ -export const SCOPED_TARGETS: readonly string[] = ["@viztor/dsh-opencode-patch"]; +export const SCOPED_TARGETS: readonly string[] = [ + "@viztor/dsh-opencode-patch", + "@viztor/dsh-opencode", +]; diff --git a/scripts/wrapper-forwarder.ts b/scripts/wrapper-forwarder.ts new file mode 100644 index 0000000..38344c0 --- /dev/null +++ b/scripts/wrapper-forwarder.ts @@ -0,0 +1,37 @@ +/** + * The retired alias's forwarder, in one place. + * + * **The wrapper re-exports the canonical package's NAMES, and nothing else.** + * It used to add `export { default } from "dsh-opencode-patch";` — and the + * canonical package has no `default` export, so every load of the wrapper was a + * link error: + * + * ``` + * SyntaxError: The requested module 'dsh-opencode-patch' + * does not provide an export named 'default' + * ``` + * + * That is a hard failure before any of the plugin runs: the alias is a + * published package, and the thing it exists for — letting an existing install + * keep resolving — is exactly what it prevented. + * + * The re-export lives here rather than inline in the publish script because it + * is written to TWO files (the runtime entry and the type declaration), and + * the two must not drift: a `.d.mts` that re-exports a name the package does + * not have is a type error for every consumer, and one that differs from the + * runtime entry is a lie in the declarations. + * + * @module scripts/wrapper-forwarder + */ + +/** The package the wrapper forwards to. */ +export const FORWARDED_PACKAGE = "dsh-opencode-patch"; + +/** + * The forwarder's source, for the runtime entry and the type declaration. + * + * `export *` and nothing else: a star re-export carries whatever names the + * target has, so it cannot name one it does not. + */ +export const wrapperForwarder = (): string => + `export * from "${FORWARDED_PACKAGE}";`; diff --git a/test/package-checks.test.ts b/test/package-checks.test.ts index df07e68..49e9de3 100644 --- a/test/package-checks.test.ts +++ b/test/package-checks.test.ts @@ -129,14 +129,22 @@ describe("package checks · the publish list and the verify list agree", () => { expect(publishListMismatch(["p", "a"], ["p", "a"])).toEqual([]); }); - it("the scoped publish list and release.yml's verify list agree, and neither names the retired wrapper", () => { + it("the scoped publish list and release.yml's verify list agree, deprecated alias included", () => { + // **The deprecated alias is published, so it is verified.** A compatibility + // name that stops publishing breaks the installs still on it, which is the + // one thing it must not do — and a name that publishes without being + // verified is how a release reports success while one of its three packages + // sits behind. const pkg = readPackage(); const pending = releasePendingTargets( readFileSync(join(ROOT, ".github", "workflows", "release.yml"), "utf8") ); expect(pending).toEqual([pkg.name, ...SCOPED_TARGETS]); - expect(SCOPED_TARGETS).toEqual(["@viztor/dsh-opencode-patch"]); - expect(pending).not.toContain("@viztor/dsh-opencode"); + expect(SCOPED_TARGETS).toEqual([ + "@viztor/dsh-opencode-patch", + "@viztor/dsh-opencode", + ]); + expect(pending).toContain("@viztor/dsh-opencode"); }); it("the gate and the publish script read those lists rather than keeping their own", () => { @@ -157,7 +165,9 @@ describe("package checks · the publish list and the verify list agree", () => { expect( [...publish.matchAll(/for \(const \w+ of (\w+)\)/gu)].map((m) => m[1]) ).toContain("SCOPED_TARGETS"); - expect(publish).not.toContain('"@viztor/dsh-opencode"'); + // The wrapper branch names the deprecated alias on purpose: it is the one + // target whose manifest is rewritten rather than copied. + expect(publish).toContain('"@viztor/dsh-opencode"'); const check = code("check.ts"); expect(check).toMatch(/\bexportsOutsideFiles\(/u); expect(check).toMatch(/\bpublishListMismatch\(/u); diff --git a/test/wrapper-forwarder.test.ts b/test/wrapper-forwarder.test.ts new file mode 100644 index 0000000..d9a196e --- /dev/null +++ b/test/wrapper-forwarder.test.ts @@ -0,0 +1,119 @@ +/** + * The retired alias's forwarder, linked against the REAL built barrel. + * + * The alias is a published package, and the thing it exists for — letting an + * existing install keep resolving — is what it broke: its forwarder re-exported + * a `default` the canonical package does not have, so every load was a link + * error before the plugin ran at all. + * + * **The check is a real Node process, not vite-node.** A bundler turns a + * missing export into `undefined` and stays green, which is how a package that + * cannot be imported ships with a passing suite. So the test writes the + * forwarder next to a copy of the BUILT `lib/index.mjs` and lets Node link it. + * + * @module test/wrapper-forwarder.test + */ + +import { execFileSync } from "node:child_process"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { wrapperForwarder } from "../scripts/wrapper-forwarder.ts"; + +/** The built barrel, which is what the wrapper forwards to. */ +const BUILT = join(import.meta.dirname, "..", "lib", "index.mjs"); + +describe("the retired alias's forwarder", () => { + it("links against the built barrel in a real Node process", () => { + // The built barrel is what ships; if it is missing, the bundle gate + // failed and there is nothing to link against. + expect( + existsSync(BUILT), + "lib/index.mjs is missing — run `pnpm run build` first" + ).toBe(true); + + const root = mkdtempSync(join(tmpdir(), "dsh-wrapper-")); + try { + // A package tree the wrapper can resolve: the canonical package under + // its name, with the barrel that was actually built. + const canonical = join(root, "node_modules", "dsh-opencode-patch"); + mkdirSync(join(canonical, "lib"), { recursive: true }); + cpSync(BUILT, join(canonical, "lib", "index.mjs")); + // The barrel's own imports have to resolve from the temp tree, or Node + // fails on the DEPENDENCY and never reaches the export check. + for (const dependency of [ + "@deepseek-ai/schemastery", + "@deepseek-ai/dsh-typert-protocol", + ]) { + const from = join( + import.meta.dirname, + "..", + "node_modules", + dependency + ); + if (!existsSync(from)) { + continue; + } + const to = join(root, "node_modules", dependency); + mkdirSync(join(to, ".."), { recursive: true }); + symlinkSync(from, to, "dir"); + } + writeFileSync( + join(canonical, "package.json"), + JSON.stringify({ + exports: { ".": "./lib/index.mjs" }, + name: "dsh-opencode-patch", + type: "module", + version: "0.0.0-test", + }) + ); + + const wrapper = join(root, "wrapper"); + mkdirSync(join(wrapper, "lib"), { recursive: true }); + writeFileSync( + join(wrapper, "lib", "index.mjs"), + `${wrapperForwarder()}\n` + ); + writeFileSync( + join(wrapper, "package.json"), + JSON.stringify({ + name: "dsh-opencode-patch-wrapper", + type: "module", + }) + ); + + // Node links it. A `default` the barrel does not have is a SyntaxError + // here — and nothing else in the suite would ever see it. + const probe = join(root, "probe.mjs"); + writeFileSync( + probe, + `import * as ns from "${join(wrapper, "lib", "index.mjs")}";\n` + + `console.log(Object.keys(ns).length);\n` + ); + const out = execFileSync(process.execPath, [probe], { + encoding: "utf8", + }); + expect(Number(out.trim())).toBeGreaterThan(0); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + it("names only what the canonical package has", () => { + // The forwarder is a star re-export and nothing else: a named re-export + // can name something the target does not have, and that is a link error. + expect(wrapperForwarder()).toBe('export * from "dsh-opencode-patch";'); + expect(wrapperForwarder()).not.toContain("default"); + }); +});