Merge remote-tracking branch 'origin/master' into otel/product-telemetry

This commit is contained in:
07akioni committed 2026-09-21 17:33:03 +08:00
commit 8b89200324
464 files changed
+16217 -3800

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-plugin-configuration-on-the-plugins-page.md
2026-09-16-plugin-configuration-on-the-plugins-page.md: 475580af48257f8c9f1d080617de1e672b74b028
2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 22d0aa4ff1e4d027ebfc3e019047c7d5e94497d7
2026-09-16-plugin-configuration-on-the-plugins-page.md: 700ada15385d0ff95e2f7f1cc58545a28677a507
2026-09-16-plugin-configuration-on-the-plugins-page.zh.md: 23935312d2b09a524a1571f81c18ad8b5ffafc3f
@@ -18,14 +18,14 @@ The page passes `view: 'page'` for each form. Official plugin cards and details
**Only a save writes.** The form has no discard control or unsaved marker; leaving a page drops its staged edits on unmount. A save writes through the client settings scope with the revision fence.
**The four host-plane pages register from `ui-settings-plugins` while the Host serves their namespaces.** The package keeps the Settings section as the **Built-in plugins** shell around the inventory tab and registers each page through `ctx.slots.inject` when the shared settings mirror shows the namespace, disposing it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it. The `settings.plugin.item` slot is retired.
**The four host-plane pages register while the Host serves their namespaces.** Each page's package registers it through `ctx.slots.inject` when the shared settings mirror shows the namespace and disposes it when the namespace goes, so a deployment that does not compose the owning plugin shows no trace of it; since [settings pages as companion packages](2026-09-17-settings-pages-as-companion-packages.md) that is one companion package per page, and `ui-settings-plugins` keeps the Settings section as the **Built-in plugins** shell around the inventory tab. The `settings.plugin.item` slot is retired.
**The Official group.** The optional bundles the installation ships open the group, tagged **Beta** where the feature is one (Agent Teams), with no official tag; the configuration pages follow. Auto review is a published experimental package used as the install guide's example, not an `OPTIONAL_BUNDLES` entry or a CLI dependency.
## Consequences
- A bundle's browser half registers a form with one slot registration and its own dictionary; the bundle's patch must declare the row under the id in the key, and the registration exists while the row that carries the bundle's browser half is on: `dsh-client-modules` attaches that half to the row whose specifier is the bare package name, so a row-level page keyed to a subpath row disappears with the root row, not with its own.
- The four pages use the existing forms and settings write path; `ui-settings-plugins` owns the Settings section while its configuration pages live on the Plugins page.
- The four pages use the existing forms and settings write path; `ui-settings-plugins` owns the Settings section, and the configuration pages live on the Plugins page.
- Settings lists the inventory only; the Settings browser goldens cover that read-only section.
## Alternatives considered
@@ -18,14 +18,14 @@ Status: implemented
**只有保存才写入。** 表单没有放弃控件或未保存标记;离开页面时在卸载过程中丢弃暂存修改。保存经由客户端 settings scope,带 revision 栅栏。
**四个宿主平面页面由 `ui-settings-plugins` 在 Host 服务其命名空间期间注册。** 该包把设置分区保留为清单标签页外面的**内置插件**外壳,在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹。`settings.plugin.item` slot 退役。
**四个宿主平面页面在 Host 服务其命名空间期间注册。** 每个页面所在的包在共享的 settings 镜像显示某个命名空间时通过 `ctx.slots.inject` 注册对应页面,命名空间消失时销毁,因此没有组装该插件的部署不会留下它的痕迹;自[设置页作为伴生包](2026-09-17-settings-pages-as-companion-packages.zh.md)起,每个页面各是一个伴生包,`ui-settings-plugins` 把设置分区保留为清单标签页外面的**内置插件**外壳。`settings.plugin.item` slot 退役。
**官方分组。** 安装随附的可选组合包开启这个分组,属于 beta 功能的(Agent Teams)带 **Beta** 标签,没有官方标签;配置页排在其后。Auto review 是安装引导用作示例的已发布实验包,不属于 `OPTIONAL_BUNDLES` 或 CLI 依赖。
## 后果
- 组合包的浏览器半侧用一次 slot 注册加自己的词典就能提供表单;组合包的 patch 必须以键里的 id 声明这一行,注册只在承载组合包浏览器半侧的那一行开启期间存在:`dsh-client-modules` 把该半侧挂在说明符恰为包名的那一行上,因此键指向子路径行的行级页面随根行消失,而不随它自己的行。
- 四个页面使用现有表单和 settings 写入路径;`ui-settings-plugins` 拥有设置分区,其配置页则位于插件页。
- 四个页面使用现有表单和 settings 写入路径;`ui-settings-plugins` 拥有设置分区,配置页则位于插件页。
- 设置只列出清单;Settings 浏览器 golden 覆盖该只读分区。
## 考虑过的替代方案
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-plugin-detail-page-extension-slots.md
2026-09-17-plugin-detail-page-extension-slots.md: d4bbca0c4466fbb78a8c56cfc4e327100385368f
2026-09-17-plugin-detail-page-extension-slots.zh.md: f07efc3d3cadded04a7551867680cc058d532943
@@ -0,0 +1,35 @@
# Agent Note: Plugin detail pages as an extension point
Status: implemented
English | [中文](2026-09-17-plugin-detail-page-extension-slots.zh.md)
## Problem
The Plugins page's detail pages — a bundle's page, a row's page, an official plugin's page — carried only what the page drew itself and the object's own configuration ([plugin configuration on the Plugins page](2026-09-16-plugin-configuration-on-the-plugins-page.md)). A plugin with something to say about a bundle it does not own — a diagnostics plugin's health check, a market plugin's update badge, a documentation plugin's README section — had no place on that bundle's page, and importing the page to extend it is forbidden by the client bundle purity rule.
## Decision
**The page declares three list slots keyed by region, not by object.** `plugins.detail.actions` renders at the head of a page before the page's own switch and uninstall; `plugins.detail.badge` beside the title after the version, beta, and problem tags; `plugins.detail.section` under the page's own content — after the rows on a bundle's page, after the configuration on a row's or an official plugin's page. Each entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`. An entry decides from the subject whether to render and returns null otherwise; the page orders entries by `order`.
**The subject is a projection, not the store.** `PluginPackageRef` and `PluginRowRef` carry the name, version, installed and enabled facts, and the row list — the facts a contribution decides on — and nothing of the page's own state, so the page's store stays private and the contract stays small.
**A row's page still exists only for a configured row.** A list entry cannot say in advance which rows it has something for, and a row page with nothing on it shows nothing worth opening, so the configure control keeps its rule: a row is opened when a `plugins.row.config` entry names it, and contributions for the row render on that page.
## Alternatives considered
**One slot per object and region (`plugins.bundle.section`, `plugins.row.section`, ...).** Rejected: nine slots for three regions, and a contribution that spans objects would register three times. The subject discriminator carries the same information in three slots.
**Passing the page's `PackageView` and `PackageRow` as the subject.** Rejected: those types carry the Host's read-only reasons, errors, entry ids, and fiber phases, which are the page's business; exposing them would make store internals a public contract.
**Every row gets a page so row contributions always have a home.** Deferred: a row without configuration has nothing a page would show beyond its module and state; the rule can widen when a contribution needs it.
## Consequences
- A contribution is a slot registration with `import type` of `ui-plugin-manager` for the declarations; the page never names a contributing plugin.
- The e2e fixture bundle (`@fixture/live-client`) contributes to its own bundle and row pages through the three slots; its browser half registers them with the rest, and they leave with it.
- The slot catalog and the slots subsystem hierarchy list the three slots under `main`.
## Testing
`packages/client/ui-plugin-manager/tests/components.client.spec.tsx` renders contributions on a bundle's page, a row's page, and an official plugin's page with the subject each is about, and pins the projection; `browser-plugin.client.spec.tsx` pins the declarations. `apps/web/tests/plugin-config.e2e.ts` switches the fixture bundle on and asserts its action, badge, and section on the bundle's page and the row's page, their absence on an official plugin's page, and holds the bundle page's golden.
@@ -0,0 +1,35 @@
# Agent Note:插件详情页作为扩展点
状态:已实现
[English](2026-09-17-plugin-detail-page-extension-slots.md) | 中文
## 问题
插件页的详情页——组合包页、行页、官方插件页——只承载页面自绘的内容和对象自己的配置([插件配置放在插件页](2026-09-16-plugin-configuration-on-the-plugins-page.zh.md))。对不属于自己的组合包有话要说的插件——诊断插件的健康检查、市场插件的更新标签、文档插件的 README 区块——在那个组合包的页面上无处安放,而通过 import 页面来扩展它被客户端包纯净性规则禁止。
## 决策
**页面按区域而不是按对象声明三个 list slot。** `plugins.detail.actions` 渲染在页头,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁,位于版本、Beta 和异常标签之后;`plugins.detail.section` 在页面自身内容之下——组合包页在组件列表之后,行页与官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`。条目根据 subject 决定是否渲染,否则返回 null;页面按 `order` 排列条目。
**subject 是投影,不是 store。** `PluginPackageRef` 与 `PluginRowRef` 携带包名、版本、是否已安装、是否启用以及行列表——贡献者据以判断的事实——不带页面自己的任何状态,因此页面的 store 保持私有,契约保持精简。
**行页仍然只为已配置的行存在。** list 条目无法预先说明它对哪些行有话要说,而一个空空如也的行页没有值得打开的内容,所以配置控件保留原规则:有 `plugins.row.config` 条目点名这一行时才可打开,对该行的贡献渲染在那个页面上。
## 考虑过的替代方案
**按对象和区域各开一个 slot(`plugins.bundle.section`、`plugins.row.section` 等)。** 否决:三个区域要九个 slot,而跨对象的贡献要注册三次。subject 判别式在三个 slot 里承载同样的信息。
**把页面的 `PackageView` 与 `PackageRow` 直接作为 subject。** 否决:这些类型携带 Host 的只读原因、错误、条目 id 与 fiber 阶段,这些是页面自己的事;暴露它们会让 store 内部成为公共契约。
**每一行都有页面,让行级贡献总有落脚处。** 推迟:没有配置的行,页面除了模块名和状态之外无可展示;等有贡献需要时再放宽这条规则。
## 后果
- 一个贡献就是一次 slot 注册,只需 `import type` `ui-plugin-manager` 的声明;页面从不点名任何贡献插件。
- e2e 夹具组合包(`@fixture/live-client`)通过三个 slot 向它自己的组合包页和行页贡献内容;它的浏览器半侧与其余注册一起完成,也一起离开。
- slot 目录与 slots 子系统层级树在 `main` 下列出这三个 slot。
## 测试
`packages/client/ui-plugin-manager/tests/components.client.spec.tsx` 在组合包页、行页和官方插件页上渲染贡献并核对各自的 subject,钉住投影;`browser-plugin.client.spec.tsx` 钉住声明。`apps/web/tests/plugin-config.e2e.ts` 打开夹具组合包,断言组合包页与行页上的操作、标签和区块、官方插件页上没有它们,并保留组合包页的 golden。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-settings-pages-as-companion-packages.md
2026-09-17-settings-pages-as-companion-packages.md: de2f2897938fcc158c49bed772f399b4d9328d19
2026-09-17-settings-pages-as-companion-packages.zh.md: 27d00faa156f0f48c6dc3995930be9ca629a732d
@@ -0,0 +1,40 @@
# Agent Note: Settings pages as companion packages
Status: implemented
English | [中文](2026-09-17-settings-pages-as-companion-packages.zh.md)
## Problem
The four official settings pages — shell, agent loop, Subagent, web search — lived in one client package, `ui-settings-plugins`, beside the Built-in plugins section. Every new built-in page grew that package, and a community bundle that wanted a settings page had no worked example of a page living with the plugin that owns the namespace. Moving each page into the Host package that registers its namespace was considered and does not fit: the shell namespace is registered by the executor families under the sandbox row, the loop package would gain a browser build for one number field, and the Subagent namespaces are registered from a subpath row, which carries no browser half.
## Decision
**A community plugin's settings page lives in its own package's browser half.** It registers into `plugins.bundle.config` or `plugins.row.config`, owns its copy and styles, and reads and writes through `ctx.settingsScope`. Its i18n is the client locale service, as the shipped community bundles already do.
**A built-in namespace's page lives in a companion client package.** `ui-settings-shell`, `ui-settings-agent-loop`, `ui-settings-subagent`, and `ui-settings-web-search` are client-only packages with an empty Host `apply`, one bare-name row each in the web composition's roster, registering into `plugins.item` while the Host serves their namespace. Host packages stay free of browser code; the namespace they edit is spelled as a literal, never imported from the owner.
**The form machinery moves to `ui-primitives`.** `SettingsFormModel` with `settingsNumberField` and `settingsTextField`, `SettingsValueField` and `SettingsSecretField`, and the `SettingsForm` frame are the kit every page renders with; the frame takes its copy as `labels` and the model takes a structural scope, so the baseline package depends on neither a plugin nor its dictionary.
**`settingsScope.whileServed(namespaces, register)` is the registration rule.** A page exists exactly while the Host serves one of its namespaces: the service watches the shared describe mirror, runs the registration when a namespace appears, and disposes it when none is served, so four packages share one rule instead of four copies.
**`ui-settings-plugins` keeps the Built-in plugins section only:** the navigation entry and the tab row feature-owned tabs register into.
## Alternatives considered
**A schema-driven generic form.** Rejected: a generic renderer covers scalar fields and stops at the first dynamic control — the Subagent page's live model catalogue, its cross-field rule, its one-mutation save — and every exception would become a role the schema is not for. Every page is code; a community plugin that ships no page has none.
**Dual-face Host packages for the built-in pages.** Rejected for the reasons above; the companion keeps the option open for a package that can carry a half.
**Copy for the form frame in one shared dictionary.** Rejected: it would couple every page to another plugin's dictionary keys; each page's dictionary carries the few frame strings.
## Consequences
- The Plugins page's Official group lists the same four pages in the same order, from four packages instead of one; their labels and forms are unchanged, so the page goldens hold.
- The cookbook [adding a settings page](../../../../docs/cookbook/adding-a-settings-card.md) shows the community path and names the companion template.
- The client module system's rule stands: a browser half rides the Loader row whose specifier is the bare package name, so a bundle split into subpath rows keeps every page it registers on its root row.
- The shell page's `plugins.item` id is `shell`, where the old package registered `bash`: `subject.id` on an official page is now a discriminator `plugins.detail.*` contributions key on, so the id names the capability the page edits rather than one executor family.
## Testing
Each companion's `tests/apply.client.spec.ts` registers its page while the Host serves the namespace, withdraws it when the Host stops, and collapses on teardown; its card and controller specs carry the page's behavior from the former package. `packages/client/ui-primitives/tests/settings-form-model.client.spec.ts`, `packages/client/ui-primitives/tests/settings-form.client.spec.tsx`, and `packages/client/ui-primitives/tests/settings-fields.client.spec.tsx` pin the kit; `packages/client/ui-settings/tests/while-served.client.spec.ts` pins the registration rule. `apps/web/tests/plugin-config.e2e.ts` exercises the shell and Subagent pages over the real wire.
@@ -0,0 +1,40 @@
# Agent Note:设置页作为伴生包
状态:已实现
[English](2026-09-17-settings-pages-as-companion-packages.md) | 中文
## 问题
四个官方设置页——终端、Agent 循环、Subagent、网页搜索——都放在一个客户端包 `ui-settings-plugins` 里,与「内置插件」分区挨着。每新增一个内置页面都让这个包再长一截,而想要设置页的社区组合包也找不到"页面与拥有命名空间的插件放在一起"的现成样板。曾考虑把每个页面搬进注册其命名空间的宿主包,但行不通:shell 命名空间由执行器家族在 sandbox 行之下注册,循环包为了一个数字字段要长出浏览器构建,Subagent 的命名空间从子路径行注册,而子路径行不携带浏览器半侧。
## 决策
**社区插件的设置页放在自己包的浏览器半侧。** 它注册进 `plugins.bundle.config` 或 `plugins.row.config`,自己拥有文案和样式,通过 `ctx.settingsScope` 读写。它的 i18n 走客户端 locale 服务,已发布的社区组合包本来就是这么做的。
**内置命名空间的页面放在伴生客户端包里。** `ui-settings-shell`、`ui-settings-agent-loop`、`ui-settings-subagent`、`ui-settings-web-search` 是纯客户端包,宿主 `apply` 为空,各在 Web 组合花名册里占一条裸名行,在 Host 服务其命名空间期间注册进 `plugins.item`。宿主包不含浏览器代码;它们编辑的命名空间以字面量拼写,从不从 owner 导入。
**表单机制搬进 `ui-primitives`。** `SettingsFormModel` 及 `settingsNumberField`、`settingsTextField`,`SettingsValueField` 与 `SettingsSecretField`,以及 `SettingsForm` 框架,是每个页面共同渲染用的套件;框架以 `labels` 接收文案,模型接受结构化的 scope,因此基线包既不依赖任何插件也不依赖其字典。
**`settingsScope.whileServed(namespaces, register)` 是注册规则。** 页面恰好在 Host 服务其某个命名空间期间存在:服务监视共享的 describe 镜像,命名空间出现时执行注册,一个都不再服务时注销,四个包共用一条规则而不是四份副本。
**`ui-settings-plugins` 只保留「内置插件」分区:** 导航项和供功能插件注册标签页的标签行。
## 考虑过的替代方案
**由 schema 驱动的通用表单。** 否决:通用渲染器只覆盖标量字段,碰到第一个动态控件就止步——Subagent 页的实时模型目录、跨字段规则、一次 mutate 提交——而每个例外都会变成一个 schema 本不该承担的 role。每个页面都是代码;没有发页面的社区插件就没有页面。
**内置页面做成双面宿主包。** 因上述原因否决;伴生包给能带半侧的包留了这个选项。
**表单框架的文案放在一本共享字典里。** 否决:那会让每个页面耦合到另一个插件的字典键;每个页面的字典自带那几条框架文案。
## 后果
- 插件页官方分组列出同样四个页面、同样顺序,来自四个包而不是一个;标签与表单未变,页面 golden 保持不变。
- 手册[添加设置页](../../../../docs/cookbook/adding-a-settings-card.zh.md)展示社区路径并点名伴生包模板。
- 客户端模块系统的规则不变:浏览器半侧挂在说明符为裸包名的 Loader 行上,因此拆成子路径行的组合包,其注册的所有页面都留在根行上。
- 终端页在 `plugins.item` 里的 id 是 `shell`,旧包注册的是 `bash`:官方页面上的 `subject.id` 现在是 `plugins.detail.*` 贡献可以据以判断的标识,所以 id 以页面编辑的能力命名,而不是某一个执行器家族。
## 测试
每个伴生包的 `tests/apply.client.spec.ts` 在 Host 服务命名空间时注册页面、停止服务时注销、teardown 时收拢;其 card 与 controller 用例延续原包里的页面行为。`packages/client/ui-primitives/tests/settings-form-model.client.spec.ts`、`packages/client/ui-primitives/tests/settings-form.client.spec.tsx` 与 `packages/client/ui-primitives/tests/settings-fields.client.spec.tsx` 钉住套件;`packages/client/ui-settings/tests/while-served.client.spec.ts` 钉住注册规则。`apps/web/tests/plugin-config.e2e.ts` 走真实链路验证终端页与 Subagent 页。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md
2026-08-08-web-background-job-display.md: 049785e9cd5dea2d92d9fc5a14dfeddac9b64469
2026-08-08-web-background-job-display.zh.md: 1dd7ee9215fa034a0a7abbe75bd5f6eb6907e9ee
2026-08-08-web-background-job-display.md: 1ebd600e4d423f95ad663c333572a990d0c9efa7
2026-08-08-web-background-job-display.zh.md: 56d062b5079e15c90cffc30b538efe162bc19d9d
@@ -99,7 +99,7 @@ A running one-shot background subagent therefore appears both there and in the s
**No web path calls `ctx.jobs.read()`.** It consumes the single output cursor, so a browser read would silently take bytes the model's `job_output` will never see. This is an invariant worth a test rather than a convention, because the failure is invisible at the call site.
**No cancellation.** That phase owes a decision the seam does not currently answer: `kill()` marks terminal delivery reported, so a human interrupt written against the `kill()` contract would leave the model believing its task is still running.
**No cancellation.** That phase owed a decision the seam then did not answer: `kill()` marked terminal delivery reported, so a human interrupt written against the `kill()` contract would leave the model believing its task is still running. The [human job kill note](2026-08-26-human-job-kill.md) later resolved it: `kill` now takes an explicit `reported` claim, and the web stop control passes `reported: false` so the completion notice stays due.
**No output watermark on the frame.** The output phase's delta channel is where an anchor field earns its place; one added now would have no reader.
@@ -133,7 +133,7 @@ Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts
**Settled rows accumulate.** The registry retains settled tasks until owner disposal, so a long session with many background commands grows a long list. Capping the settled tail is a presentation change, not a protocol one, if it becomes a real complaint.
**`stopping` is rarely visible.** Only the model's `job_kill` produces it, so the state is rendered but rarely seen until human cancellation lands. It is in the union now because leaving a status out would have made that phase a wire change.
**`stopping` is visible on every kill path.** The model's `job_kill` and the web stop control both produce it; carrying it in the union from the start is what kept the human-kill phase off the wire format.
**Two entry points for one running subagent.** Accepted deliberately, and bounded to one-shot background delegations. If it reads as noise in practice, the fix is presentational — the catalog row can cite the task rather than the task list hiding the kind.
@@ -99,7 +99,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
**没有任何 Web 路径调用 `ctx.jobs.read()`。** 它消费唯一的输出游标,浏览器读一次就悄悄拿走了模型 `job_output` 永远看不到的字节。这该是一条有测试兜底的不变量而不是一条约定,因为它的故障在调用点完全不可见。
**不做中断。** 那一期欠一个 seam 目前没有回答的决策:`kill()` 会把终态投递标为已上报,所以照 `kill()` 契约写出来的人类中断,会让模型一直以为它的任务还在跑。
**不做中断。** 那一期欠一个 seam 当时没有回答的决策:`kill()` 会把终态投递标为已上报,所以照 `kill()` 契约写出来的人类中断,会让模型一直以为它的任务还在跑。[人工任务终止 Note](2026-08-26-human-job-kill.zh.md) 后来解决了它:`kill` 现在接受显式的 `reported` 认领,Web 停止控件传 `reported: false`,完成通知因此仍会投递。
**帧上不带输出水位。** 输出那一期的增量通道才是锚点字段该出现的地方;现在加就是一个没有读者的字段。
@@ -133,7 +133,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
**终态行会堆积。** 注册表把已结算任务留到 owner 销毁,所以一个跑了很多后台命令的长会话会积出长列表。如果真的成为抱怨,给终态尾巴加上限是呈现层改动而非协议改动。
**`stopping` 很少可见。** 只有模型的 `job_kill` 会产生它,所以这个状态会被渲染但在人类中断落地之前很少见到。现在就纳入联合类型,是因为把它留在外面会让那一期变成一次线路变更。
**`stopping` 在每条 kill 路径上都可见。** 模型的 `job_kill` 与 Web 停止控件都会产生它;从一开始就把它放进联合类型,正是人工 kill 这一期不必动线路格式的原因。
**一个运行中的 subagent 有两个入口。** 这是刻意接受的,且被限制在一次性后台委派这一种情况。如果实际用起来读着像噪声,修法是呈现层的——可以让目录行引用那个任务,而不是让任务列表隐藏这个 kind。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-human-job-kill.md
2026-08-26-human-job-kill.md: efffdbd85bf2fa665565ff584a6938cfee827516
2026-08-26-human-job-kill.zh.md: 206acca008518dd689351e40a99ff1571f3d1a8f
@@ -0,0 +1,36 @@
# Agent Note: Human job kill — an unclaimed terminal report instead of a second cancellation path
Status: implemented
Update: the `reported` option this note introduced is gone with the [jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md). The registry keeps no report bit; `dsh-tool-jobs` claims only its own `job_kill` and waits in a ledger, so a human `job.kill` leaves the notice due by construction. The reason merge, the `job.kill` Remote, and the two-press control are unchanged.
English | [中文](2026-08-26-human-job-kill.zh.md)
## Problem
The [web job display note](2026-08-08-web-background-job-display.md) shipped the task list read-only and recorded why: `JobRegistry.kill()` marks the job `reported`, and the [`dsh-tool-jobs`](../../../../packages/jobs/tool-jobs/README.md) completion reporter suppresses the settlement notice for a reported job. That coupling is correct for the one caller that existed — the model's `job_kill`, whose own tool result already tells the model what it did — but a human pressing a stop button has no model-visible channel at all. A kill written against that contract would leave the model believing its task is still running, exactly the stale-world-model failure Claude Code ships today (its `/tasks` kill sets `notified: true` and the model learns nothing) and Kimi avoids (a model `TaskStop` suppresses its own notification; a human stop delivers one).
## Decision
A delivery claim means "the terminal state has a committed delivery path to the model", so the fix is to stop conflating cancellation with claiming that delivery.
- **A human kill claims no delivery.** `JobRegistry.kill(id, { reason? })` records the reason only; the delivery ledger lives in `dsh-tool-jobs`, which claims a job for the model's own `job_kill` and for its waits, so a `job.kill` from the task list leaves the settlement to the existing completion reporter (wakeup/inject, wake budget, truncation all unchanged). It never clears a claim the model already holds, and a terminal record stays exactly as settlement left it.
- **A recorded kill reason merges into a `killed` settlement's detail** — producer facts first (`signal: SIGTERM; cancelled by the user`) — so the completion notice and the web row both say who stopped the work without a new snapshot field or notice template. A job that outruns its kill (settles `completed`/`failed`) keeps the producer detail alone. This is the reconciliation Codex spells out in its `<turn_aborted>` guidance: when the user stops something, the model is told rather than left to infer.
- **`job.kill` is a Job Controller Remote** (`@deepseek-ai/dsh-api-job-controller`, beside `job.follow`; it began life as `session.killJob` on the Session Controller and moved with the observation stream when that package split out) with the same live-only Agent lookup (`ctx.agents.get`) as `session.cancel`: a running job's owner is alive by the registry's ownership contract, and killing from a list must not revive a Session any more than listing does. Unknown and foreign jobs collapse to one `job/not-found` rejection scoped to the lookup — a producer-cancel throw propagates per the registry contract instead of masquerading as a lookup failure; the controller requires `ctx.jobs` to load, so a composition without a registry has no kill Remote at all. The registry's owner fence is the only access rule: the Session Controller's subagent ownership fence is not applied, so a child session's own jobs are killable from its list (review rejected exporting that fence's helpers from the Session Controller package, and the rule adds nothing for a job the child owns), and the controller depends on neither the agent registry nor the Session Controller. No approval interaction: this single-user local BFF treats it as the same class of action as the turn-cancel button.
- **The task list's stop control is two-press** (arm, then confirm within 3s, matching the Kimi `s`+`y` and OpenCode double-Esc shape), rendered only on running job rows — rows with a `jobId`. Standalone activity rows (workflow runs) have no kill handle and no button. The pending press disables the control and stays pending on an admitted kill — the unary response and the jobs frames have no cross-carrier ordering, so only the authoritative frame (the row leaving the killable set) releases it; a rejected kill shows a brief hint. Because the kill addresses `ctx.jobs` by id, every job kind gains the control at once — bash, pwsh, pty, and one-shot background subagents.
## Alternatives considered
- **A `notify: boolean` option** — rejected because the registry cannot promise a notice (quiet delivery, unowned jobs, owner teardown all legitimately drop one); the ledger's claim names the fact the tool actually controls: whether one of its own tool results already delivered the terminal state.
- **A separate `interrupt()`/`stopByUser()` method** — one cancellation path with an explicit claim beats two methods whose only difference is a boolean, and Kimi's split (`stop` vs `stopByUser`) shipped with its TUI calling the wrong one.
- **Saying nothing to the model (Claude Code's shape)** — rejected; their own source comments question it, and the DSH blocker note recorded the stale-belief failure as the reason the control did not ship earlier.
## Testing
`tool-jobs` pins the ledger (a kill the model did not request leaves the notice due; its own `job_kill` and waits claim it) and the delivered notice text verbatim, reason included; `jobs-local` pins the detail merge on `killed` and the outran-kill case. `kill.host.spec.ts` pins the Remote command: admission, unowned-job kill from an agentless session, `job/not-found` for unknown/foreign. The client suites pin the RPC passthrough and the two-press control (arm, confirm, disarm timer, failure hint, single armed row, stale-phase cleanup); the keyless web e2e drives the button end to end.
## Consequences
- The completion notice for a human kill wakes an idle owner (default `wakeup` delivery) — a deliberate cost: an unclaimed completion the model never learns about is the failure this note exists to fix.
- `kill`'s positional `reason` parameter is gone (pre-release, no shim); the options object is the only form.
- A killed job's `detail` may now carry two clauses joined by `; `. Anything parsing `detail` as a single producer fact must treat it as opaque text, which `JobView.detail` always declared it to be.
@@ -0,0 +1,36 @@
# Agent Note:人工任务终止——用「未认领的终态报告」而非第二条取消路径
状态:已实现
Update:本文引入的 `reported` 选项已随 [jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md) 消失。注册表不再保留播报位;`dsh-tool-jobs` 只在台账里认领自己的 `job_kill` 与等待,因此人类的 `job.kill` 天然让通知保持待送达。原因合并、`job.kill` Remote 与两击式控件保持不变。
[English](2026-08-26-human-job-kill.md) | 中文
## 问题
[Web 任务展示 Note](2026-08-08-web-background-job-display.zh.md) 把任务列表交付为只读并记下了原因:`JobRegistry.kill()` 会把任务标记为 `reported`,而 [`dsh-tool-jobs`](../../../../packages/jobs/tool-jobs/README.zh.md) 的完成播报员对已 reported 的任务抑制结算通知。这个耦合对当时唯一的调用方是正确的——模型的 `job_kill` 自己的 tool result 已经告诉模型它做了什么——但按下停止按钮的人没有任何模型可见渠道。照那份契约写一个人工 kill,模型会一直以为任务还在跑,正是 Claude Code 今天在售的过期世界模型失败(其 `/tasks` kill 置 `notified: true`,模型什么也学不到),也正是 Kimi 避开的(模型 `TaskStop` 抑制自己的通知;人工 stop 投递一条)。
## 决策
送达认领的本义是「终态已有一条向模型送达的承诺路径」,所以修法是不再把「取消」与「认领这次送达」混为一谈。
- **人类的 kill 不认领送达。** `JobRegistry.kill(id, { reason? })` 只记录原因;送达台账在 `dsh-tool-jobs` 里,它只为模型自己的 `job_kill` 与等待认领任务,因此来自任务列表的 `job.kill` 让结算照常走完成播报员(唤醒/注入、唤醒预算、截断全部不变)。它绝不清除模型已持有的认领,终态记录也保持结算时的原样。
- **记录下来的 kill reason 合入 `killed` 结算的 detail**——生产者事实在前(`signal: SIGTERM; cancelled by the user`)——完成通知与 Web 行因此都能说清是谁停的工作,无需新快照字段或新通知模板。跑赢 kill 的任务(结算为 `completed`/`failed`)只保留生产者 detail。这正是 Codex 在 `<turn_aborted>` 指引里写明的对账原则:用户停掉了什么,要告诉模型,而不是让它去猜。
- **`job.kill` 是 Job Controller 的 Remote**(`@deepseek-ai/dsh-api-job-controller`,与 `job.follow` 并列;它最初是 Session Controller 上的 `session.killJob`,该包拆出时随观测流一起迁走),与 `session.cancel` 一样只查活体 Agent(`ctx.agents.get`):按注册表的所有权契约,在跑任务的 owner 必然活着;从列表里 kill 与列表本身一样绝不复活 Session。未知与他人任务合并为一个只作用于查找的 `job/not-found` 拒绝——生产者 cancel 抛错按注册表契约原样传播,绝不伪装成查找失败;控制器加载就要求 `ctx.jobs`,没有注册表的组合根本没有 kill Remote。注册表的所有者围栏是唯一的访问规则:不施加 Session Controller 的 subagent 所有权栅栏,子会话自己的 job 可以从它的列表里被杀停(评审否决了从 Session Controller 包导出该栅栏的实现函数,而这条规则对子会话自有的 job 也没有额外意义),控制器既不依赖 agent 注册表也不依赖 Session Controller。不加 approval 交互:这个单用户本地 BFF 把它视为与停轮按钮同级的操作。
- **任务列表的停止控件为两击式**(先武装、3 秒内确认,对齐 Kimi 的 `s`+`y` 与 OpenCode 的双 Esc),只渲染在运行中的 job 行——即带 `jobId` 的行。独立 activity 行(workflow 运行)没有 kill 句柄也就没有按钮。请求进行中按钮禁用,且受理成功后保持 pending——unary 响应与 jobs 帧没有跨载体顺序保证,只有权威帧(行离开可杀集合)才释放控件;被拒绝的 kill 短暂提示。由于 kill 按 id 寻址 `ctx.jobs`,所有任务 kind 一次性获得该控件——bash、pwsh、pty 与一次性后台 subagent。
## 备选方案
- **`notify: boolean` 选项**——否决:注册表承诺不了通知(quiet 投递、无主任务、owner 拆除都会正当地丢弃一条);台账的认领命名的是工具真正控制的事实:它自己的某个 tool result 是否已经送达了终态。
- **独立的 `interrupt()`/`stopByUser()` 方法**——一条取消路径带显式认领胜过两个只差一个布尔的方法;Kimi 的拆分(`stop` 与 `stopByUser`)上线时 TUI 就调错了那个。
- **对模型保持沉默(Claude Code 的形状)**——否决;他们自己的源码注释都在质疑它,而 DSH 的阻塞 Note 正是把过期认知失败记为该控件此前不能上线的原因。
## 测试
`tool-jobs` 钉住台账(不是模型请求的 kill 让通知保持待送达;它自己的 `job_kill` 与等待认领它)与逐字的带原因投递通知文本;`jobs-local` 钉住 `killed` 的 detail 合并与跑赢 kill 的情形。`kill.host.spec.ts` 钉住 Remote 命令:受理、无 agent 会话 kill 无主任务、未知/他人的 `job/not-found`。客户端套件钉住 RPC 透传与两击控件(武装、确认、解除计时、失败提示、单一武装行、过期相位清理);无密钥 web e2e 端到端驱动按钮。
## 后果
- 人工 kill 的完成通知会唤醒空闲 owner(默认 `wakeup` 投递)——这是刻意的代价:模型永远学不到的未认领完成,正是本 Note 要修的失败。
- `kill` 的位置参数 `reason` 已删除(pre-release,无垫片);options 对象是唯一形式。
- 被 kill 任务的 `detail` 可能携带以 `; ` 连接的两个子句。任何把 `detail` 当单一生产者事实解析的代码都必须把它当不透明文本——`JobView.detail` 一直如此声明。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.md
2026-08-26-shell-execute-projection-and-jobs-at-start.md: 2a03cd9f99645c10377dc9d2a5fdc68cb5ab0a4a
2026-08-26-shell-execute-projection-and-jobs-at-start.zh.md: bdc6cc432dbb8e3a0a8d0fc86cf055f3938ff201
@@ -0,0 +1,52 @@
# Agent Note: One shell execute() — foreground as a projection, timeout as a bounded wait on the job
Status: implemented
Update: the [jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md) owns the output ring, the cursors, and the pull sources every registered command feeds.
English | [中文](2026-08-26-shell-execute-projection-and-jobs-at-start.zh.md)
## Problem
A bash command that outran its foreground timeout was killed, discarding the work — the single most common way a long build or install failed under the agent. Fixing that inside the old seam was structurally awkward: `ctx.shell` had two execution methods, `run()` (deadline fused in, promise-only, no handle to keep) and `start()` (handle, no deadline), so "keep this already-running foreground command" had no expression — the deadline owner could only kill, and the caller had nothing to re-register with `ctx.jobs`. The two methods had also drifted: different stdout budgets, a documented "start ignores timeoutMs" wart, and sync-vs-async spawn-failure behavior that differed per path.
Peer evidence pointed one way. Kimi keeps a timed-out command running by default (`bashAutoBackgroundOnTimeout`), rebinding nothing — the process task detaches, the caller signal unhooks, and the partial output returns with the handle. Claude Code's timeout handler calls `background()` on the same `ShellCommand` instead of killing, with an explicit comment that respawning would leak cleanups and duplicate events. Codex has no hand-over because it never had a "foreground spawn" at all: every `exec_command` is a bounded observation window over a session, and "still running" is a normal return carrying the handle. All three agree on the underlying shape: there is one way to execute, and foreground is a property of the wait, not the spawn.
The first shipped version of this change expressed the hand-over on the seam: an `onExpiry: 'offer'` policy, `ShellExecution.promotion`, and a `ShellPromotionOffer` the tool had to answer synchronously at the deadline. Review rejected it: the protocol existed only because the tool registered the job at the timeout instead of at the start, so every executor carried a relay/timer/detach arm and every consumer a timing obligation that no execution needs.
## Decision
**The seam converges to `resolve()` + `execute()`.** `execute(spec)` returns `Promise<ShellExecution>` — the live `ShellProcess` itself plus the foreground projection `result()` (split collected streams, first-cause `timedOut`/`aborted`, rejecting only for infrastructure failures). `run()` and `start()` are deleted (pre-release, no shims); the historical differences they encoded became explicit inputs: `ShellExecSpec.onExpiry` is `'kill'` (the default) or `'none'`, and one stdout budget (`spec.stdoutMaxBytes`) applies everywhere. There is no hand-over protocol: a caller that wants to wait only for a while runs the command under `'none'` and bounds its own wait, and the handle stays valid after the caller stops waiting.
**Preparation shares the execution deadline.** The handle is published after asynchronous confinement finishes. Preparation cancellation or failure rejects `execute`; expiry returns a settled timed-out handle with empty output. A late preparation result cannot spawn work. Provider facts are installed synchronously before subprocess settlement so an immediately failed launch still receives its sandbox classification.
**Spawn failures are contained uniformly**: a synchronous spawn throw and an asynchronous rejection both settle the handle as `killed` with the note on the read path, while `result()` rejects with the original error (identity preserved — the sandbox executors map runner-attributed failures to `SANDBOX_UNAVAILABLE` in their `result()` decoration and stamp handle facts in `onProcessDone`, both keyed on the one handle instance).
**With a job registry composed, `tool-bash`/`tool-pwsh` register every command with `ctx.jobs` at its start.** `run_in_background` returns the id at once. A foreground call starts the same job and waits on it with the registry's `wait(id, timeoutMs, owner, signal)`, the existing `timeoutMs` being the wait bound — no second knob. A command that settles within the wait returns the ordinary foreground result from the handle's `result()` and the tool removes the job record (`JobRegistry.remove`), so the model never sees an id and the list does not accumulate every `ls`. A command that outlives the wait keeps running as the job it already was, and the call returns `{ kind: 'promoted', jobId, timeoutMs, output }` — rendered as `[still running after <ms>; moved to background job <id>]` plus hand-off guidance — seeded with one consuming registry read, so `job_output` continues exactly after it. Nothing changes hands at the timeout: not the process, not the abort signal, not the output. The tool never passes its own signal to the process; cancelling the call kills the job, and a kill from outside the call (the human's stop control, a parallel `job_kill`) reaches the process through the job's `cancel` hook, whose reason the tool renders as `[stopped: <reason>]` in the foreground result (`stopped` on the value) so the model reads why instead of a command failure.
**The background surface follows the registry, and the registry stays optional.** The tool's `inject` does not name `jobs`. It registers a foreground-only definition while no registry is composed, replaces it with the job-backed definition inside a `ctx.inject(['jobs'], …)` fork for as long as `ctx.jobs` is present, and restores the foreground-only one when the registry unloads while the plugin stays. A composition without the registry therefore keeps a plain `bash` whose schema advertises neither `run_in_background` nor the hand-over, and the model sees no `job_*` tools. Registration is best-effort: `promoteOnTimeout: false`, or a registry that refuses the job at its start (the owner's admission limit, no attached controller), runs the command under the executor's `'kill'` deadline instead, logged.
**The registry reports what a waiting caller already collected.** The `settled` event carries `awaited`: whether the settlement released a live `wait`, which is true for the tool waiting on its own foreground command exactly as for a `job_output` wait. `dsh-tool-jobs` withholds the completion notice for awaited settlements and for kills the model requested through `job_kill`, and its private claim ledger is gone; a timed-out or aborted wait has already left the registry's waiter set, so a later settlement notifies as usual. A human kill from the web controller is neither, so the owning agent still receives the notice, reason included.
## Alternatives considered
- **A third method (`begin()`) beside `run`/`start`** — the first design. Rejected in review: the three "methods" were two orthogonal inputs (deadline policy; which projection the caller awaits) wearing method names, and the peer products all model one execution with projected views. Converging deleted the budget/wart drift instead of adding a surface.
- **Codex's session model** (every call an observation window, polling via `write_stdin`) — rejected as a model-facing vocabulary change duplicating what `ctx.jobs` already provides; its early-yield result contract survives in the promoted arm.
- **A separate hand-over threshold config** — rejected; "timeout means stop blocking the turn, not kill the work" needs no second timer, and an explicit model-passed `timeoutMs` bounds the wait the same way (both peers behave so; the schema text says it).
- **A promotion offer on the seam** (`onExpiry: 'offer'`, `ShellExecution.promotion`, `ShellPromotionOffer` with a synchronous `accept()`/`decline()` answer) — the version this PR first shipped. Rejected in review because the deferred registration was the only reason for the protocol: the executor's offer arm relayed the caller's signal, armed its own timer, and detached the signal on accept, all of which a consumer holding the handle can do itself, and none of which is needed once the job exists from the start.
- **A tool-owned deadline over `onExpiry: 'none'` without registering at start** — removes the seam protocol but keeps the command invisible and unkillable until the timeout; rejected because a listed, stoppable command from its first second is the point of the human kill.
- **A hard `jobs` dependency for the shell tools** — rejected: a composition that wants a shell without three model-visible `job_*` tools must stay possible, so the tool swaps definitions on the registry's presence instead.
- **Exempting waited-on commands from the admission cap, and an `attached` marker in `JobView`** — deferred: a refusal at the start falls back to the deadline kill, and grouping running foreground commands in the Web task list is a follow-up.
## Testing
Executor level (real processes): the `'kill'` and `'none'` arms classify first-cause; a caller abort before the deadline stays the first cause when the process outlives the deadline; a pre-aborted signal classifies `aborted`; preparation expiry yields a settled timed-out handle without spawning (sandbox suites); sync-throw containment with error identity through `result()`. Tool level: a real `printf …; sleep 30` with `timeoutMs: 250` is listed as `bash-1` while the call waits, returns the still-running text with the early output, and its next `job_output` read repeats nothing; a command that finishes within the wait returns the foreground result with the registry announcing `registered`, `settled` (`awaited: true`), `removed` and an empty list afterwards; a registry kill during the wait renders `[stopped: cancelled by the user]` before the signal marker; cancelling the call kills its job with `tool call aborted` as the reason; a start failure is the tool's error result with no job left; admission saturation falls back to the deadline kill with the warn; `promoteOnTimeout: false` keeps the kill, emits no job events, and drops the description sentence; the schema flips to job-backed when a registry loads later and back to foreground-only when it unloads, while disposing the tool plugin under a registry removes the tool without logging; pwsh mirrors every case over a scripted executor. Registry level: `awaited` for a live wait, `false` for none, timed-out, or aborted waits, and `true` while another wait on the job timed out; `remove` drops a settled record with a `removed` event and refuses a live job, an unknown id, and a foreign caller. Render texts are pinned verbatim.
## Consequences
- Every `ShellExecutor` consumer moved: the tools, the hook runner, tmux-context, and the webworker sandbox stack now speak `execute()`; executor test suites keep scenario intent through local `run`/`start` shims over the new seam.
- The `bash`/`pwsh` tool descriptions and `timeoutMs` schema text depend on the registry's presence (model-visible); the output union carries the `promoted` arm, and the foreground arm gained the optional `stopped` reason — the PTC system-prompt snapshots re-recorded with it.
- A registered command has no deadline at all: for a foreground call the timeout bounds only the wait, and stopping the command afterwards is `job_kill` or the web stop control.
- The Web task list shows every foreground command while it runs and streams it through `job.list`/`job.follow`; a settled one leaves with its result.
- `JobRegistry` gained `remove` and the `settled` event's `awaited` flag; `dsh-tool-jobs` keeps only the set of jobs the model killed.
- `start()`-era behavior where a synchronous spawn throw escaped to the caller is gone: callers read failures from `result()`/the read path. The sandbox "sync EACCES names the runner" classification now arrives as the `result()` rejection or the handle's `runnerFailed` fact.
@@ -0,0 +1,52 @@
# Agent Note:唯一的 shell execute()——前台是投影,超时是对 job 的有界等待
状态:已实现
Update:[jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md)拥有每条已登记命令现在填充的输出环、游标与拉取源。
[English](2026-08-26-shell-execute-projection-and-jobs-at-start.md) | 中文
## 问题
跑过前台超时的 bash 命令会被杀掉、工作作废——这是长构建或安装在 agent 手里失败的头号方式。在旧 seam 里修它结构上很别扭:`ctx.shell` 有两个执行方法,`run()`(deadline 内置、只给 promise、没有可保留的句柄)与 `start()`(有句柄、无 deadline),于是「保住这个已在运行的前台命令」无从表达——deadline 的所有者只会杀,调用方也拿不到可向 `ctx.jobs` 重新注册的东西。两个方法还各自漂移:stdout 预算不同、文档里挂着「start 忽略 timeoutMs」的疣、同步与异步 spawn 失败的行为按路径分叉。
同行证据指向同一个方向。Kimi 默认让超时的命令继续运行(`bashAutoBackgroundOnTimeout`),什么都不重绑——进程任务脱离、调用方信号解钩、部分输出随句柄返回。Claude Code 的超时处理对同一个 `ShellCommand` 调用 `background()` 而不是杀掉,并明确注释重新 spawn 会泄漏清理并重复事件。Codex 根本没有移交,因为它从未有过「前台 spawn」:每次 `exec_command` 都是对一个 session 的有界观察窗,「仍在运行」是携带句柄的正常返回。三家在底层形状上一致:执行只有一种方式,前台是等待的属性,不是 spawn 的属性。
本变更最初交付的版本把移交放在了 seam 上:`onExpiry: 'offer'` 策略、`ShellExecution.promotion`,以及工具必须在 deadline 处同步应答的 `ShellPromotionOffer`。评审否决了它:这套协议存在的唯一原因是工具把 job 登记推迟到了超时点而不是启动点,于是每个执行器都要背一条中继/计时/解绑分支,每个消费方都要背一条任何执行都不需要的时序义务。
## 决策
**seam 收敛为 `resolve()` + `execute()`。** `execute(spec)` 返回 `Promise<ShellExecution>`——活的 `ShellProcess` 本体加前台投影 `result()`(分流收集、首因 `timedOut`/`aborted` 归类、只为基础设施失败 reject)。`run()` 与 `start()` 删除(pre-release,无垫片);它们编码的历史差异变成显式输入:`ShellExecSpec.onExpiry` 取 `'kill'`(默认)或 `'none'`,stdout 预算统一为 `spec.stdoutMaxBytes`。没有任何移交协议:只想等一段时间的调用方以 `'none'` 运行命令并自己限定等待时长,调用方停止等待后句柄依然有效。
**准备与执行共用 deadline。** 异步沙箱准备完成后才发布句柄。准备期间的取消或失败会拒绝 `execute`;到期则返回已结算的超时句柄,输出为空。迟到的准备结果不会启动任务。提供方事实在 subprocess 结算前同步安装,立即失败的启动也能获得对应的沙箱归类。
**spawn 失败统一收容**:同步抛错与异步 rejection 都把句柄结算为 `killed`、说明进入读路径,而 `result()` 以原始错误 reject(保留同一性——沙箱执行器在其 `result()` 装饰里把可归因于 runner 的失败映射为 `SANDBOX_UNAVAILABLE`,并在 `onProcessDone` 里盖章句柄事实,两者都以同一个句柄实例为键)。
**组合中有 job 注册表时,`tool-bash`/`tool-pwsh` 在每条命令启动时就把它登记到 `ctx.jobs`。** `run_in_background` 立即返回 id。前台调用启动同样的 job,并用注册表的 `wait(id, timeoutMs, owner, signal)` 等待它,等待上限就是现有的 `timeoutMs`——没有第二个旋钮。在等待内结算的命令用句柄的 `result()` 返回普通前台结果,工具随即移除该 job 记录(`JobRegistry.remove`),模型从不看到 id,列表也不会堆满每一条 `ls`。超过等待仍在运行的命令继续作为它本来就是的那个 job 运行,调用返回 `{ kind: 'promoted', jobId, timeoutMs, output }`——渲染为 `[still running after <ms>; moved to background job <id>]` 加交接指引——并以一次消费式注册表读取打底,因此 `job_output` 恰好从此处接续。超时那一刻没有任何东西易手:进程没有、中止信号没有、输出也没有。工具从不把自己的信号交给进程;取消调用会杀掉 job,而来自调用之外的杀停(人的停止控件、并行的 `job_kill`)经 job 的 `cancel` 钩子到达进程,工具把其原因渲染为前台结果里的 `[stopped: <reason>]`(值上的 `stopped`),模型读到的是原因而不是命令失败。
**后台能力面跟随注册表,注册表保持可选。** 工具的 `inject` 不写 `jobs`。没有注册表时它注册纯前台定义;`ctx.jobs` 出现后,`ctx.inject(['jobs'], …)` fork 用带 job 的定义替换它,注册表存在多久就保持多久;注册表在插件仍在时卸载,则恢复纯前台定义。因此没有注册表的组合保留一个 schema 既不宣传 `run_in_background` 也不宣传移交的普通 `bash`,模型也看不到任何 `job_*` 工具。登记是尽力而为的:`promoteOnTimeout: false`,或注册表在启动时拒绝该 job(持有者的准入上限、没有已附加的控制器),都改为在执行器的 `'kill'` deadline 下运行命令,并记录日志。
**注册表报告等待方已经收走了什么。** `settled` 事件带 `awaited`:本次结算是否释放了一个存活的 `wait`,工具等待自己的前台命令时为真,`job_output` 的等待亦然。`dsh-tool-jobs` 对已 awaited 的结算和模型经 `job_kill` 请求的杀停不发完成通知,其私有的认领台账已删除;超时或中止的等待早已离开注册表的等待方集合,之后的结算照常通知。来自 web 控制器的人类杀停两者都不是,因此拥有者 agent 照常收到带原因的通知。
## 备选方案
- **在 `run`/`start` 旁加第三个方法(`begin()`)**——最初的设计。评审中否决:三个「方法」其实是披着方法名的两根正交输入(deadline 策略;调用方等待哪个投影),同行产品全都建模为一次执行加投影视图。收敛顺带删掉了预算/疣的漂移,而不是再添一层表面。
- **Codex 的 session 模型**(每次调用都是观察窗、经 `write_stdin` 轮询)——否决:那是重复 `ctx.jobs` 已有能力的模型侧词汇变更;其早退结果契约保留在 promoted arm 里。
- **独立的移交阈值配置**——否决;「超时意味着别再阻塞回合,而不是杀掉工作」不需要第二个计时器,模型显式传的 `timeoutMs` 同样限定等待(两家同行皆如此;schema 文案已说明)。
- **seam 上的转移 offer**(`onExpiry: 'offer'`、`ShellExecution.promotion`、需同步 `accept()`/`decline()` 应答的 `ShellPromotionOffer`)——本 PR 最初交付的版本。评审否决,因为推迟登记是这套协议存在的唯一原因:执行器的 offer 分支中继调用方信号、自设计时器、accept 时解绑信号,这些持有句柄的消费方都能自己做,而 job 一开始就存在后一样都不需要。
- **不在启动时登记、只由工具在 `onExpiry: 'none'` 之上自设 deadline**——去掉了 seam 协议,但命令在超时前依然不可见、不可杀;否决,因为从第一秒起就被列出、可停止的命令正是人类杀停的意义所在。
- **shell 工具硬依赖 `jobs`**——否决:想要 shell 却不想让模型看到三个 `job_*` 工具的组合必须仍然可能,所以工具改为随注册表的存在切换定义。
- **让被等待的命令不占准入名额,以及 `JobView` 上的 `attached` 标记**——延期:启动时被拒回落到 deadline 杀,Web 任务列表对运行中前台命令的分组留作后续。
## 测试
执行器层(真实进程):`'kill'` 与 `'none'` 两个分支按首因归类;deadline 前的调用方 abort 在进程活过 deadline 时仍是首因;预先 abort 的信号归类 `aborted`;准备到期返回已结算的超时句柄且不 spawn(沙箱套件);同步抛错收容且错误同一性穿过 `result()`。工具层:真实的 `printf …; sleep 30` 配 `timeoutMs: 250` 在调用等待期间就以 `bash-1` 列出,返回带早期输出的仍在运行文本,其下一次 `job_output` 读取不重复任何内容;在等待内完成的命令返回前台结果,注册表依次播报 `registered`、`settled`(`awaited: true`)、`removed`,之后列表为空;等待期间的注册表杀停在信号标记前渲染 `[stopped: cancelled by the user]`;取消调用以 `tool call aborted` 为原因杀掉其 job;启动失败是工具的错误结果且不留 job;准入饱和回落到 deadline 杀并告警;`promoteOnTimeout: false` 保持杀、不发任何 job 事件、撤掉描述句;注册表稍后加载时 schema 切到带 job 的版本、卸载时切回纯前台,而在注册表存在时释放工具插件会移除工具且不记录任何错误;pwsh 以脚本化执行器镜像每个用例。注册表层:存活等待为 `awaited`,无人等待、超时或中止的等待为 `false`,另一个等待超时时仍为 `true`;`remove` 以 `removed` 事件丢弃已结算记录,并拒绝存活 job、未知 id 与外部调用方。渲染文本逐字钉死。
## 后果
- 每个 `ShellExecutor` 消费方都已迁移:工具、hook runner、tmux-context 与 webworker 沙箱栈现在都说 `execute()`;执行器测试套件用本地 `run`/`start` 垫片保住场景语义。
- `bash`/`pwsh` 的工具描述与 `timeoutMs` schema 文案取决于注册表是否存在(模型可见);输出 union 保留 `promoted` arm,前台 arm 新增可选的 `stopped` 原因——PTC 系统提示词快照随之重录。
- 已登记的命令完全没有 deadline:前台调用的超时只限定等待,之后停下命令靠 `job_kill` 或 Web 停止控件。
- Web 任务列表显示每条运行中的前台命令并经 `job.list`/`job.follow` 流式观看;完成的命令随结果离开。
- `JobRegistry` 新增 `remove` 与 `settled` 事件的 `awaited` 标志;`dsh-tool-jobs` 只保留模型杀停过的 job 集合。
- `start()` 时代同步 spawn 抛错逃逸给调用方的行为不复存在:调用方从 `result()`/读路径读失败。沙箱「同步 EACCES 指名 runner」的分类现在以 `result()` rejection 或句柄的 `runnerFailed` 事实呈现。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.md
2026-09-01-workflow-run-in-background.md: 5a141b384186e7b4f9e8125c37eaf2c01a614a6d
2026-09-01-workflow-run-in-background.zh.md: 102a398d95eef6df07443a919c19dae57b5c1e74
@@ -0,0 +1,31 @@
# Agent Note: Workflow runs gain `run_in_background` over the job registry
Status: implemented
Update: with the [jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md) the run registers a `JobSpec` (no `record` flag), narrates through `JobHandle.append` and `updateProgress`, and returns its rendered value as `JobOutcome.result`, which the model's first read after settlement carries once.
English | [中文](2026-09-01-workflow-run-in-background.zh.md)
## Problem
A `workflow` call blocked the parent turn until the whole script settled: a long orchestration (an audit fanning out over hundreds of files) held the model hostage for its entire wall-clock, with no way to keep working, no live progress for a human, and cancellation as the only exit. Every other long-running execution surface — bash, pwsh, one-shot subagents — already had a `run_in_background` route into `ctx.jobs`. The [record merge](../architecture/2026-09-01-jobs-absorb-activity-record.md) also removed the foreground workflow's activity mirror on the explicit promise that live workflow narration would return as a background record job.
## Decision
The `workflow` tool gains `run_in_background: true` (exposed and accepted while the `enableRunInBackground` config holds, default on): the call registers the run as an owned `kind: 'workflow'` job with an observation record and returns `{ kind: 'background', jobId, runId }` immediately.
- **The run belongs to the job, not the tool step.** The engine run starts inside the job starter with no `exec.signal`; `job_kill`, the job list's stop control, and owner teardown are the cancellation paths, each forwarding its reason into `run.cancel`. A synchronous engine rejection (meta/parse failure) propagates out of the starter, so nothing registers and the model sees the ordinary correctable error.
- **Settlement is the job's settlement.** `done` chains from `run.result`: dispose (a disposal failure warns and never rejects into the registry), stop the mirrors, then map the stop reason — `completed` carries the same rendered return value as the foreground path in `JobOutcome.output` (so the completion notice and `job_output` deliver it), `cancelled` settles `killed` and leaves the detail to the registry's kill-reason merge (the forwarded cancel reason is the same string), `error` settles `failed` with the script's failure message.
- **The record mirror replaces the deleted activity mirror.** `src/record.ts` subscribes `workflow/phase`, `workflow/log`, and member lifecycle events once per plugin and routes them into tracked runs' `JobHandle` faces as the same text lines the activity mirror wrote, on the `log` channel so the model's `job_output` never renders them; `updateProgress` tracks the current phase. There is no contained-error wrapper: `append`/`updateProgress` are non-throwing surfaces (post-settlement appends drop inside the registry), and a straggling event finds no tracked run.
- **The output schema becomes a `kind`-discriminated union** (`background` | `foreground`), matching bash's shape; the foreground envelope gains `kind: 'foreground'` for symmetry. The durable run-start/member/run-end session recording keeps its `exec.parent === undefined` gate on both paths, with the background run-end written from the job's `done` chain.
- **`workflow` joins `JobKindMap`** by declaration merging from the tool package, like `pwsh` and `pty`.
## Alternatives considered
- **A start/poll API on the tool itself** (a `workflow_status` companion): duplicates `job_output`/`job_kill` for one producer and leaves the run outside owner teardown and the session-header list; the job registry already is the start/poll surface.
- **Streaming intermediate values to the model** (partial results through `readOutput`): a workflow's value is the script's single return; per-agent intermediates are script-internal (`log()` narrates them for humans through the record). A consuming model cursor would invite polling loops against an inherently final-value producer.
- **Bridging `exec.signal` into the background run:** the returning tool step's abort (turn cancellation) would kill work the model just intentionally detached; bash's background route set the precedent that only the registry cancels.
## Consequences
The model can fire long orchestrations and keep working; humans watch phases, logs, and member lifecycles stream in the session-header job list and can stop the run there. A background run's value arrives only at settlement (`job_output` before that returns status alone). The snapshot tree pins the schema, prompt, and PTC stub changes; a recorded-session scenario replaying a full background run is deferred and tracked in the package README's limitations.
@@ -0,0 +1,31 @@
# Agent Note:workflow 运行经任务注册表获得 `run_in_background`
状态:已实现
Update:随 [jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md),该运行注册的是 `JobSpec`(没有 `record` 标志),通过 `JobHandle.append` 与 `updateProgress` 叙述进度,并把渲染后的值作为 `JobOutcome.result` 返回,模型在结算后的第一次读取携带它一次。
[English](2026-09-01-workflow-run-in-background.md) | 中文
## 问题
`workflow` 调用会阻塞父级轮次直到整个脚本结算:一次长编排(扇出到数百个文件的审计)把模型按在原地耗完全部墙钟时间,模型无法继续工作,人类看不到实时进度,取消是唯一出口。其他每个长时执行面——bash、pwsh、一次性 subagent——都早已有通往 `ctx.jobs` 的 `run_in_background` 路径。[record 合并](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)删掉前台 workflow 的 activity 镜像时,也明确承诺实时 workflow 叙述会以后台 record 任务的形式回归。
## 决策
`workflow` 工具获得 `run_in_background: true`(在 `enableRunInBackground` 配置生效时公开并受理,默认开启):调用把运行注册为自有的 `kind: 'workflow'` 任务并声明观察 record,立刻返回 `{ kind: 'background', jobId, runId }`。
- **运行属于任务,不属于工具步骤。** 引擎运行在任务 starter 内启动且不带 `exec.signal`;取消路径是 `job_kill`、任务列表的停止控件与 owner 释放,各自把原因转发进 `run.cancel`。引擎的同步拒绝(meta/解析失败)从 starter 传播出去,因此什么都不注册,模型看到的是普通的可修正错误。
- **结算即任务结算。** `done` 从 `run.result` 链起:先 dispose(释放失败只告警,绝不 reject 进注册表),再停掉镜像,最后映射结束原因——`completed` 在 `JobOutcome.output` 里携带与前台路径相同的渲染返回值(完成播报与 `job_output` 由此送达它),`cancelled` 结算为 `killed` 并把 detail 留给注册表的 kill 原因合并(转发的取消原因就是同一字符串),`error` 以脚本失败消息结算为 `failed`。
- **record 镜像顶替被删的 activity 镜像。** `src/record.ts` 按插件订阅一次 `workflow/phase`、`workflow/log` 与成员生命周期事件,并把它们以 activity 镜像当年写下的同样文本行以 `log` 通道路由进被跟踪运行的 `JobHandle` 面(模型的 `job_output` 从不渲染它们);`updateProgress` 跟随当前 phase。这里没有收容错误的包装层:`append`/`updateProgress` 是不抛出的表面(结算后的 append 在注册表内丢弃),迟到的事件找不到被跟踪的运行。
- **输出 schema 变为 `kind` 判别联合**(`background` | `foreground`),与 bash 的形态一致;前台包络为对称获得 `kind: 'foreground'`。持久的 run-start/成员/run-end 会话记录在两条路径上都保留 `exec.parent === undefined` 门槛,后台的 run-end 从任务的 `done` 链写出。
- **`workflow` 进入 `JobKindMap`**,由工具包声明合并,与 `pwsh`、`pty` 相同。
## 曾考虑的替代方案
- **工具自带 start/poll 接口**(一个 `workflow_status` 伴生工具):为单一生产者复制 `job_output`/`job_kill`,还把运行留在 owner 释放与会话头部列表之外;任务注册表本身就是 start/poll 表面。
- **向模型流式中间值**(经 `readOutput` 给部分结果):workflow 的值就是脚本的单一 return;每个 agent 的中间结果是脚本内部事务(`log()` 经 record 向人类叙述它们)。给天然只有终值的生产者一个消费型模型游标,只会招来轮询循环。
- **把 `exec.signal` 桥接进后台运行:** 已返回的工具步骤的中止(轮次取消)会杀掉模型刚刚有意脱管的工作;bash 的后台路径已立下先例——只有注册表取消它。
## 后果
模型可以点燃长编排后继续工作;人类在会话头部任务列表里看着 phase、log 与成员生命周期流入,并能就地停止运行。后台运行的值只在结算时抵达(此前的 `job_output` 只返回状态)。快照树钉住 schema、提示词与 PTC 桩的变化;回放完整后台运行的 recorded-session 场景延期,记录在包 README 的限制一节。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md
2026-09-08-desktop-uninstall-preserve-dsh-home.md: 921d9681aa6a26c39e8861d2156dc868237f5b37
2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md: cb9187bb6d7c3f619dc083fcc3b36cca09494515
@@ -0,0 +1,39 @@
# Agent Note: Windows uninstall retains the Harness home
Status: implemented
English | [中文](2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md)
## Problem
Desktop and CLI share conversations, credentials, settings and plugins in the Harness home. Electron keeps browser storage and caches under `%APPDATA%`, and the updater keeps downloaded installers under `%LOCALAPPDATA%`; the upstream NSIS template leaves both behind unless a command-line flag is passed. Users who uninstall expect the application to leave no residue, while users who reinstall expect their conversations back.
## Decision
Windows uninstallation removes the Electron user-data directory, the `%APPDATA%` product directory and the updater cache together with the application, and never touches the Harness home. This is the 2026-09-08 proposal in amended form: Windows only, without the installed-artifact inventory programme, and without a user choice. No custom uninstaller page exists; silent uninstallation removes the same data. An uninstaller started with `--updated` or `/KEEP_APP_DATA` retains it, which covers electron-builder's in-place update and its replacement of an older installation from another directory, so updates keep UI preferences. A `DSH_HOME` published as a Windows environment variable protects every target overlapping it; the native helper cannot see a home configured only in a shell profile.
Electron derives its user-data directory from the scoped package name, so the removal targets `%APPDATA%\@deepseek-ai\dsh-desktop` and afterwards removes ordinary empty parents below `%APPDATA%`, never a linked or populated scope directory. Removal runs through the native helper in `window-frame.dll`, which rejects protected Windows folders and paths overlapping the installation or the protected home, holds directory handles with listing access during traversal so the directory cannot be renamed or replaced underneath it, unlinks reparse points without entering their targets, clears the read-only attribute, and keeps deleting siblings after a locked file. It requires a fixed local drive and leaves a linked root or ancestor in place. A failure leaves residue and does not stop or report on uninstallation; the uninstaller has no custom copy.
Installation also records `InstallLocation` on the Windows uninstall entry as standard inventory metadata. Launching the uninstaller directly from the Start menu context menu is unavailable to Win32 installers on Windows 11; only MSIX packages uninstall from there, so that request is outside this decision.
## Alternatives considered
**Opt-in removal on a custom uninstaller page, including the desktop profile and the whole home.** The desktop profile, sessions and credentials belong to the home shared with the CLI and stay in place for reinstallation; the remaining Electron data holds only UI preferences and caches, which does not justify a choice the user must understand, a recorded home path for the uninstaller, and a bilingual native dialog.
**Delete the whole home when desktop is the only profile.** Profile presence does not establish whether shared data is needed by CLI or a future reinstall.
**Leave Electron data in place, matching the upstream default.** Chromium caches grow to hundreds of megabytes and are the residue users report after uninstalling.
**Use electron-builder's `deleteAppDataOnUninstall`.** The upstream `RMDir /r` follows no ownership rules, ignores long paths and enters linked directories; the native helper owns removal instead.
**Establish the cleanup inventory by observing disposable accounts across install, update and uninstall.** The three roots follow from Electron's `userData` derivation and electron-builder's updater cache name, both fixed by configuration; observation would confirm what the configuration already states.
## Deferred
macOS cleanup (a signed helper behind an **Uninstall and keep Harness data** action, since drag-to-Trash cannot run application code) remains open under #3540 and is not decided here.
## Consequences
Reinstallation restores conversations, settings and plugins but not UI layout preferences. The desktop profile under the home remains with its packages and lock files. Other historical homes and user projects are not discovered or deleted. Roaming profiles on network or removable drives are left in place.
Qualification uses isolated product identities with a per-run scoped package name. Source regressions cover template adaptation; the native remover regression covers refused roots, the protected home, rename blocking while pinned, read-only and locked files, and empty-parent removal; the uninstaller fixtures cover interactive, silent, `--updated`, `/KEEP_APP_DATA`, and a `DSH_HOME` inside the Electron directory, with a junction to an external project inside the removed data. The standard uninstaller pages render correctly at 200% scaling on the development host.
@@ -0,0 +1,39 @@
# Agent Note: Windows 卸载保留 Harness 主目录
Status: implemented
[English](2026-09-08-desktop-uninstall-preserve-dsh-home.md) | 中文
## 问题
Desktop 和 CLI 在 Harness 主目录中共享会话、凭据、设置和插件。Electron 把浏览器存储和缓存放在 `%APPDATA%` 下,更新器把下载的安装包放在 `%LOCALAPPDATA%` 下;上游 NSIS 模板在没有命令行参数时会把两者都留下。卸载的用户期望应用不留残余,重装的用户期望会话仍在。
## 决策
Windows 卸载随应用一起删除 Electron 用户数据目录、`%APPDATA%` 下的产品目录和更新器缓存,从不触碰 Harness 主目录。这是 2026-09-08 提案的修正形式:仅限 Windows,不含已安装产物清单方案,也不让用户选择。卸载程序没有自定义页面;静默卸载删除相同的数据。以 `--updated` 或 `/KEEP_APP_DATA` 启动的卸载程序保留数据,这覆盖了 electron-builder 的原地更新和从其他目录替换旧安装两种情况,因此更新保留界面偏好。以 Windows 环境变量发布的 `DSH_HOME` 会保护所有与其重叠的目标;原生辅助程序看不到只在 shell 配置文件中设置的主目录。
Electron 根据带作用域的包名派生用户数据目录,因此删除目标是 `%APPDATA%\@deepseek-ai\dsh-desktop`,随后移除 `%APPDATA%` 之下普通且已空的父目录,从不移除链接或非空的作用域目录。删除通过 `window-frame.dll` 中的原生辅助程序执行:它拒绝受保护的 Windows 目录以及与安装目录或受保护主目录重叠的路径,遍历期间以列举权限持有目录句柄,使目录无法在其下被改名或替换,遇到重解析点只解除链接而不进入目标,清除只读属性,并在遇到被占用文件后继续删除其余兄弟项。它要求固定的本地驱动器,链接的根目录或祖先目录会原样保留。失败只留下残余,不中止也不提示卸载;卸载程序没有任何自定义文案。
安装时还会在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据。Windows 11 上 Win32 安装包无法让开始菜单右键菜单中的“卸载”直接启动卸载程序,只有 MSIX 包能做到,因此该需求不在本决策范围内。
## 备选方案
**在自定义卸载页上按需删除,包括桌面配置档案和整个主目录。** 桌面配置档案、会话和凭据属于与 CLI 共享的主目录,为重装而保留;剩余的 Electron 数据只包含界面偏好和缓存,不值得让用户理解并做出选择,也不值得为卸载程序记录主目录路径并维护双语原生对话框。
**当桌面是唯一配置档案时删除整个主目录。** 配置档案的存在不能证明共享数据是否被 CLI 或将来的重装需要。
**沿用上游默认,保留 Electron 数据。** Chromium 缓存会增长到数百 MB,正是用户卸载后反馈的残余。
**使用 electron-builder 的 `deleteAppDataOnUninstall`。** 上游的 `RMDir /r` 不遵守归属规则、不支持长路径并会进入链接目录;改由原生辅助程序负责删除。
**通过在一次性账户上观察安装、更新和卸载来建立清理清单。** 三个根目录由 Electron 的 `userData` 派生规则和 electron-builder 的更新器缓存名决定,两者都由配置固定;观察只会确认配置已经说明的事实。
## 推迟事项
macOS 清理(由签名辅助程序支撑的“**卸载并保留 Harness 数据**”操作,因为拖到废纸篓无法运行应用代码)仍在 #3540 下开放,本决策不涉及。
## 结果
重装恢复会话、设置和插件,但不恢复界面布局偏好。主目录下的桌面配置档案及其包和锁文件保留。其他历史主目录和用户项目不会被发现或删除。位于网络或可移动驱动器上的漫游配置文件原样保留。
验收使用每次运行唯一的带作用域包名的隔离产品身份。源码回归覆盖模板适配;原生删除器回归覆盖被拒绝的根目录、受保护的主目录、持有期间阻止改名、只读与被占用文件,以及空父目录移除;卸载夹具覆盖交互、静默、`--updated`、`/KEEP_APP_DATA`,以及位于 Electron 目录内的 `DSH_HOME`,被删除的数据内含指向外部项目的 junction。标准卸载页面在开发机 200% 缩放下渲染正确。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md
2026-09-16-browser-excel-preview.md: 5631906931122e45d4381a570c095203655b6435
2026-09-16-browser-excel-preview.zh.md: 4107b986246415bc640b683650d2b18f03a786c8
2026-09-16-browser-excel-preview.md: 208bcdcd69d8ac30fcb5a7776271942424a92a05
2026-09-16-browser-excel-preview.zh.md: c48d9156e44befaa265d5c1f4116d86efe099fda
@@ -16,7 +16,7 @@ Preview is read-only and retains formula text alongside saved results. It does n
A disposable browser Worker contains parser CPU work. A source-byte limit precedes parsing, a combined rectangular-area limit precedes FortuneSheet matrix allocation, and a timeout terminates stalled parsing. These limits do not claim a hard memory sandbox. Styles are scoped to the renderer; external hyperlinks are text only.
The pinned FortuneSheet React dependency carries a [patch](../../../../patches/@[email protected]) that skips the input box's read-only layout state update unless formula-reference highlighting is enabled. The unconditional update otherwise causes a maximum-update-depth failure when switching worksheets under React 19. Sheet activation also retains the adapter's complete initial selection instead of clearing it and leaving an empty or invalid address. The browser scenario covers these failures through the bundled ESM entry.
The pinned FortuneSheet React dependency carries a [patch](../../../../patches/@[email protected]) that skips the input box's read-only layout state update unless formula-reference highlighting is enabled. The unconditional update otherwise causes a maximum-update-depth failure when switching worksheets under the shipped React 18.3.1 runtime. Sheet activation also retains the adapter's complete initial selection instead of clearing it and leaving an empty or invalid address. The browser scenario covers these failures through the bundled ESM entry.
The read-only formula bar takes formulas from raw `cell.f` and writes formulas and cell text through `textContent`. FortuneSheet's formula formatter emits HTML spans containing document text, and its HTML helper passes formulas and strings starting with `<span` through unchanged. Neither read-only mode nor the parser Worker isolates this DOM. The [core patch](../../../../patches/@[email protected]) HTML-escapes copied cell contents, including literal strings and saved formula results, while preserving the clipboard table and formatting.
@@ -16,7 +16,7 @@ PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。
独立可释放的浏览器 Worker 承担解析 CPU 工作。解析前检查源文件字节限制,FortuneSheet 分配矩阵前检查矩形区域合计限制,并通过超时终止停滞的解析。这些限制不代表严格的内存沙箱。样式限定在渲染器区域内,外部超链接仅显示为文本。
固定版本的 FortuneSheet React 依赖带有一个[补丁](../../../../patches/@[email protected]):只读输入框仅在启用公式引用高亮时才提交布局状态更新。无条件更新会在 React 19 下切换工作表时触发最大更新深度错误。激活工作表时还会保留适配层提供的完整初始选区,避免清空后留下空白或无效地址。浏览器场景通过打包后的 ESM 入口覆盖这些故障。
固定版本的 FortuneSheet React 依赖带有一个[补丁](../../../../patches/@[email protected]):只读输入框仅在启用公式引用高亮时才提交布局状态更新。无条件更新会在仓库交付的 React 18.3.1 运行时下切换工作表时触发最大更新深度错误。激活工作表时还会保留适配层提供的完整初始选区,避免清空后留下空白或无效地址。浏览器场景通过打包后的 ESM 入口覆盖这些故障。
只读公式栏直接读取原始 `cell.f`,通过 `textContent` 写入公式和单元格文本。FortuneSheet 的公式格式化器会生成含有文档文本的 HTML span,其 HTML 辅助函数会原样放行公式和以 `<span` 开头的字符串。只读模式和解析 Worker 都不能隔离这部分 DOM。[core 补丁](../../../../patches/@[email protected])会对复制的单元格内容进行 HTML 转义,包括字面字符串和已保存的公式结果,同时保留剪贴板表格及其格式。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-20-config-schema-dump-validation-policy.md
2026-09-20-config-schema-dump-validation-policy.md: a0f15b6aad1f534f80db15185a9a70d5c22bc7c2
2026-09-20-config-schema-dump-validation-policy.zh.md: d94f040057e0a221057a8410523e1e154a8ae166
@@ -0,0 +1,31 @@
# Agent Note: Config schema dump validation policy
Status: implemented
English | [中文](2026-09-20-config-schema-dump-validation-policy.zh.md)
## Problem
`dsh --profile <name> --dump-config-schema` projects native Schemastery Config declarations into JSON Schema without mounting plugins. Native validation mutates its input, evaluates `!!js` expressions, and runs transform callbacks; a static document cannot reproduce all of that. Three choices were contested in review and would be reopened without a record: what `complete` and the exit code mean when part of the projection is approximate, which rows the collector inspects, and how the projection treats native behavior it cannot model.
## Decision
- **Partial projection exits 1.** `x-cordis.complete` is false and the CLI exits 1 for any error diagnostic, any `partial`, `unsupported`, or `error` entry, or one plugin name resolving to several Config definitions, even when stdout holds a valid and useful schema. The exit code answers whether the document can replace native validation, not whether output was produced. Callers that only need the schema read stdout and ignore the exit code; the diagnostics under `x-cordis` name each limitation at every entry it affects.
- **Discovery is declaration-oriented and includes disabled rows.** The collector walks every row the Loader would receive, including rows disabled literally or by expression, and imports their modules. A disabled row's import or include failure therefore makes a bootable profile incomplete. The one exception follows the Loader: a disabled group or include without `group: true` never creates its children, so a missing carrier config is recorded as a childless tree and the entry schema does not validate its child declarations.
- **Widen instead of simulating native mutation.** Where native resolution can alter the input before later validation reads it (adapted values written back by a container, renamed dictionary keys, loose fallbacks, lazy metadata propagation into shared nodes), the projection keeps the available declaration detail beside an unrestricted alternative and records a limitation. It never emulates the mutation. Widening is scoped to the mechanism: only containers write adapted values back, so a bare primitive transform as an earlier union branch keeps `anyOf`.
The [CLI reference](../../../../apps/cli/reference/README.md#config-schema-dump) documents the resulting behavior; [app-boot](../../../../packages/boot/app-boot/README.md) owns the collector API.
## Alternatives considered
**Exit 0 with a valid document and warnings only.** Rejected: a consumer feeding the schema to an editor or agent would treat an approximate document as authoritative. A nonzero exit forces the caller to decide explicitly.
**Skip disabled rows during discovery.** Rejected: a disabled row is a declaration the user is likely to re-enable, and its schema, id target, and import failures are what an editing agent needs. Skipping would also change `configRef` targets whenever `disabled` toggles.
**A relaxed child-list variant for ancestor-disabled children.** Rejected for now: it needs a parallel `entryList` and `entry` rule set with requiredness removed. Children under a disabled `group: true` row and patch insertions into a disabled group are validated as enabled, and the reference says so.
**Emulating native mutation in the projection.** Rejected: transform callbacks are plugin code the dump must not execute, and reproducing writeback order would couple the projector to Schemastery internals. A widening with a limitation is cheaper to maintain and cannot reject inputs that native validation accepts.
## Consequences
A shipped profile whose rows use an unsupported carrier reports `complete: false` and exits 1 while still emitting usable schemas, so consumers must read `x-cordis.diagnostics` to distinguish an approximate document from an unusable one. Every widening is paired with a limitation diagnostic, and generated schemas validate the `--dump-config` output of every shipped profile.
@@ -0,0 +1,31 @@
# Agent Note: 配置 schema dump 的验证策略
Status: implemented
[English](2026-09-20-config-schema-dump-validation-policy.md) | 中文
## Problem
`dsh --profile <name> --dump-config-schema` 在不挂载插件的情况下,把原生 Schemastery Config 声明投影为 JSON Schema。原生验证会修改输入、求值 `!!js` 表达式并执行 transform 回调;静态文档无法完整重现这些行为。评审中有三项选择存在争议,若不记录会被反复重提:投影部分近似时 `complete` 和退出码的含义、收集器检查哪些行,以及投影如何处理无法建模的原生行为。
## Decision
- **部分投影以 1 退出。** 只要存在错误诊断、任何 `partial`、`unsupported` 或 `error` 条目,或同一插件名解析出多个 Config 定义,`x-cordis.complete` 即为 false 且 CLI 以 1 退出,即使 stdout 已包含有效且有用的 schema。退出码回答的是该文档能否替代原生验证,而不是是否产生了输出。只需要 schema 的调用方读取 stdout 并忽略退出码;`x-cordis` 下的诊断会在受影响的每个条目上说明各项限制。
- **发现面向声明,并包含禁用行。** 收集器遍历 Loader 将接收的每一行,包括字面量禁用或表达式禁用的行,并导入它们的模块。因此禁用行的导入或 include 失败会使可启动的 profile 被标为不完整。唯一的例外遵循 Loader:未设置 `group: true` 的禁用 group 或 include 从不创建其子项,所以缺失的承载 config 记录为没有子项的树,entry schema 也不验证其子声明。
- **放宽而不是模拟原生修改。** 当原生解析可能在后续验证读取前改变输入(容器写回适配值、字典键改名、loose 回退、lazy 元数据传播到共享节点)时,投影保留可获得的声明细节,同时并列一个不受限制的备选项,并记录一条限制。它绝不模拟该修改。放宽的范围限定于该机制:只有容器会写回适配值,因此作为前序 union 分支的裸原始类型 transform 保留 `anyOf`。
[CLI 参考](../../../../apps/cli/reference/README.zh.md#config-schema-dump)记录了由此产生的行为;[app-boot](../../../../packages/boot/app-boot/README.zh.md) 负责收集器 API。
## Alternatives considered
**输出有效文档并仅给出警告,以 0 退出。** 被否决:把 schema 交给编辑器或 agent 的消费者会把近似文档当作权威。非零退出迫使调用方显式决定。
**发现时跳过禁用行。** 被否决:禁用行是用户很可能重新启用的声明,其 schema、id 目标和导入失败正是编辑 agent 所需。跳过还会导致 `configRef` 目标随 `disabled` 切换而变化。
**为祖先被禁用的子项提供放宽的子列表变体。** 暂被否决:它需要一套去掉必填约束的并行 `entryList` 和 `entry` 规则。禁用的 `group: true` 行之下的子项以及插入到禁用 group 的 patch 按启用状态验证,参考文档已如此说明。
**在投影中模拟原生修改。** 被否决:transform 回调是 dump 不得执行的插件代码,而重现写回顺序会把投影器与 Schemastery 内部实现耦合。带限制说明的放宽维护成本更低,也不会拒绝原生验证接受的输入。
## Consequences
使用不受支持承载插件的已发布 profile 会报告 `complete: false` 并以 1 退出,同时仍输出可用的 schema,因此消费者必须读取 `x-cordis.diagnostics` 来区分近似文档与不可用文档。每次放宽都配有一条限制诊断,生成的 schema 能验证每个已发布 profile 的 `--dump-config` 输出。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.md
2026-09-16-desktop-release-version-derivation.md: 0f1d917cb0d382453fbb0dca561a1eae7d530d67
2026-09-16-desktop-release-version-derivation.zh.md: e9576e30232b5eb247de047cb67f51db5fa43ae3
2026-09-16-desktop-release-version-derivation.md: 54a7b9d52c63268f15209ad0a84bcd8da035dcee
2026-09-16-desktop-release-version-derivation.zh.md: dddf4b773217965380c4487803971311b57e0863
@@ -10,11 +10,11 @@ Replacing a dsh prerelease identifier with the update channel name loses the bas
## Decision
The [Desktop release rules](../../../../apps/desktop/README.md#release-versions) preserve the complete dsh base for production and derive dated, indexed test versions from that base. Final release-family package versions remain equal. The base is retained separately before manifests are retagged, so another test release cannot append a second date suffix.
The [Desktop release rules](../../../../apps/desktop/README.md#release-versions) preserve the complete dsh base for production and derive dated, indexed test versions from that base. The derived version is [passed to packaging as an argument](2026-09-21-desktop-build-version-as-input.md) rather than written into manifests, and it is derived from the dsh base so another test release cannot append a second date suffix.
The fixed Nightly feed is a distribution address, independent of version derivation. Existing clients continue to use that address with prerelease updates enabled and downgrades disabled. Test distribution does not publish the unsuffixed base. Operators check existing release records and objects before assigning an index.
The packaging decision retains runtime/version equality; the installed-update materials decision retains private identities and data isolation. Their Nightly naming examples do not define the test version policy. Historical release receipts and frozen archived records remain evidence of what was actually built.
The installed-update materials decision retains private identities and data isolation. Its Nightly naming examples do not define the test version policy. Historical release receipts and frozen archived records remain evidence of what was actually built.
## Alternatives considered
@@ -10,11 +10,11 @@ Status: implemented
## 决策
[Desktop 发布规则](../../../../apps/desktop/README.zh.md#release-versions)在 production 中保留完整 dsh 基础版本,并从该基础版本派生带日期和序号的测试版本。最终发布家族包版本保持一致。修改清单版本前单独记录基础版本,避免下一次测试发布追加第二个日期后缀。
[Desktop 发布规则](../../../../apps/desktop/README.zh.md#release-versions)在 production 中保留完整 dsh 基础版本,并从该基础版本派生带日期和序号的测试版本。派生出的版本[作为参数传给打包](2026-09-21-desktop-build-version-as-input.zh.md),不写入清单;它从 dsh 基础版本派生,因此下一次测试发布不会追加第二个日期后缀。
固定 Nightly feed 是分发地址,与版本派生独立。现有客户端继续使用该地址,允许预发布更新并禁止降级。test 分发不发布无后缀基础版本。操作者分配序号前检查已有发布记录和对象。
打包决策继续规定运行时与包版本一致性;安装版更新物料决策继续规定私有身份和数据隔离。其中的 Nightly 命名示例不定义测试版本规则。历史发布回执和已冻结归档保留为实际构建的证据。
安装版更新物料决策继续规定私有身份和数据隔离。其中的 Nightly 命名示例不定义测试版本规则。历史发布回执和已冻结归档保留为实际构建的证据。
## 考虑过的替代方案
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-21-desktop-build-version-as-input.md
2026-09-21-desktop-build-version-as-input.md: 8653c1ed580ac360e0c2f5145781303f15fc29b2
2026-09-21-desktop-build-version-as-input.zh.md: 0a995ab5740a73de00b2a417d07640b3c07e4690
@@ -0,0 +1,37 @@
# Agent Note: Pass the Desktop build version as an argument instead of rewriting manifests
Status: implemented
English | [中文](2026-09-21-desktop-build-version-as-input.zh.md)
## Problem
[Deriving Desktop test versions](2026-09-16-desktop-release-version-derivation.md) fixed what a test build is called but not where that name lives. Publishing one meant running `release:dsh` to rewrite the version in every release-family manifest and the lockfile — 295 files — because packaging, the update feed, and upload validation each read the version from the checkout. Those edits are never committed, so a test build left the working tree dirty until someone reverted it, and a reverted tree no longer describes the artifacts that were published. The index in `<base>.YYYYMMDD.index` was also chosen by hand against the records and the bucket, which is a lookup a script can do.
Two more facts about a build were unrecoverable afterwards. A build handed to a colleague or pushed to a test feed is reachable from no tag, and the build tree it came from is not a checkout, so nothing connected an installer to its sources. A production release was likewise recorded only in the bucket.
## Decision
The version a build publishes is an argument. `--build-version` names it, `--build-version auto` proposes the next index for the day, and the value reaches electron-builder through `extraMetadata`, the update feed, and the upload validation as one input. Manifests keep the product version, so no packaging run modifies tracked files.
The version rules in the [Desktop release rules](../../../../apps/desktop/README.md#release-versions) are unchanged: production publishes the dsh base, a prerelease base takes `.YYYYMMDD.index`, and a stable base takes `-test.YYYYMMDD.index`. Validation uses `semver` itself, because `electron-updater` compares feed versions with `semver.gt` against the installed `app.getVersion()`.
This supersedes two requirements of that note. Release-family manifests are no longer rewritten, and the shell version no longer equals the bundled runtime version: the runtime is the dsh package at the product version, and `verifyDesktopRuntime` receives the version whoever prepared that tree wrote, which installed-update qualification rewrites for its own materials. The application already reported the two separately to the mandatory update policy.
Every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest. A production upload tags the packaged commit as `desktop-v<version>` after the artifacts are public, and reports the command to run by hand rather than failing an upload that already completed; a build from a modified checkout is not tagged. Test and local builds are deliberately left untagged, because a tag per test build would bury the releases.
Upload reads the published version from the completion record packaging wrote, not from a variable, so [release fields still come only from the target file](../../../../apps/desktop/README.md#release-versions).
## Alternatives considered
**Keep rewriting manifests and revert afterwards.** The dirty tree is the defect, and a revert races anything else reading the workspace. It also leaves the published artifacts describing a state the repository no longer has.
**Rewrite only the packed runtime's manifest in a temporary copy.** The bundled dsh would then claim a version that does not exist on npm, and the copy would have to be kept consistent with the lockfile for no gain.
**Number every build automatically without an argument.** Operators confirm the version with the user before packaging. Automatic numbering is offered as `auto`, which prints what it chose, rather than as the default.
**Tag every build.** Test feeds receive builds continuously; tagging each one would make `desktop-v*` useless for finding releases. Artifacts carry their commit instead.
## Consequences
A packaging run leaves the repository unchanged, so the tree that built an artifact is the tree the tag or the recorded commit names. `auto` contacts the destination bucket, including under `--check`, and falls back to the local output directory when no bucket is configured or the listing cannot complete within its deadline; a partial listing is discarded rather than risking a reused index that would overwrite a published installer. Uploading a production release now writes to the repository, which earlier release automation deliberately avoided; the write is one tag, performed by the operator's own command after the upload succeeds.
@@ -0,0 +1,37 @@
# Agent Note:Desktop 发布版本改为命令参数,不再改写清单
Status: implemented
[English](2026-09-21-desktop-build-version-as-input.md) | 中文
## 问题
[从完整 dsh 版本派生 Desktop 测试版本](2026-09-16-desktop-release-version-derivation.zh.md)确定了测试构建叫什么,但没有确定这个名字存在哪里。发布一次测试构建要先运行 `release:dsh`,把版本写进所有发布家族清单和锁文件——295 个文件——因为打包、更新 feed 和上传校验都各自从工作区读取版本。这些改动从不提交,所以每次测试构建都会留下脏工作区直到有人还原,而还原后的工作区又不再描述已经发布出去的产物。`<base>.YYYYMMDD.index` 里的序号同样靠人对着发布记录和 bucket 挑选,而这是脚本可以完成的查询。
另有两件事在构建之后无法还原。交给同事或推到测试 feed 的构建没有任何标签可查,它来自的构建树也不是工作区,因此没有任何东西把安装包和源码连起来。production 发布同样只在 bucket 里留下痕迹。
## 决策
构建发布的版本是一个参数。`--build-version` 指定它,`--build-version auto` 给出当天的下一个序号,该值经 `extraMetadata` 进入 electron-builder,并作为同一个输入贯穿更新 feed 与上传校验。清单保留产品版本,因此任何打包运行都不修改被跟踪的文件。
[Desktop 发布规则](../../../../apps/desktop/README.zh.md#release-versions)中的版本规则不变:production 发布 dsh 基础版本,预发布基础版本追加 `.YYYYMMDD.index`,稳定基础版本追加 `-test.YYYYMMDD.index`。校验直接使用 `semver`,因为 `electron-updater` 就是用 `semver.gt` 把 feed 版本与已安装的 `app.getVersion()` 比较。
本决策取代上述 Note 的两条要求。发布家族清单不再被改写;外壳版本也不再等于内置运行时版本:内置运行时就是产品版本的 dsh 包,`verifyDesktopRuntime` 接收准备该运行时树的一方写入的版本,而安装升级验证会为自己的材料改写这个版本。应用本来就把两者分别上报给强制更新策略。
所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`。production 上传在产物公开后把打包所用 commit 打成 `desktop-v<版本>` 标签,失败时只打印需要手工执行的命令,而不让已完成的上传变成失败;来自有改动工作区的构建不打标签。test 与本地构建有意不留标签,因为为每个测试构建打标签会淹没真正的发布。
上传从打包写下的完成记录中读取发布版本,而不是从变量读取,因此[发布字段仍然只来自目标文件](../../../../apps/desktop/README.zh.md#release-versions)。
## 考虑过的替代方案
**继续改写清单,事后还原。** 脏工作区本身就是缺陷,而还原会与任何同时读取工作区的操作竞争,也会让已发布产物所描述的状态在仓库中不复存在。
**只在临时副本里改写打包运行时的清单。** 内置 dsh 会因此宣称一个 npm 上不存在的版本,副本还得与锁文件保持一致,没有任何收益。
**不要参数,全部自动编号。** 操作者需要在打包前与用户确认版本。自动编号以 `auto` 提供并打印所选版本,而不是作为默认行为。
**为每个构建打标签。** 测试 feed 持续接收构建,逐个打标签会让 `desktop-v*` 无法再用于查找发布。改由产物自身携带 commit。
## 影响
打包运行不再改动仓库,因此构建产物的代码树就是标签或记录的 commit 所指的那棵树。`auto` 会访问目标 bucket,`--check` 下同样如此;未配置 bucket 或列举未能在期限内完成时回退到本地输出目录,不完整的列举结果会被丢弃,以免复用序号覆盖已发布的安装包。production 上传现在会写入仓库,而早先的发布自动化刻意避免这一点;这次写入只是一个标签,由操作者自己的命令在上传成功后执行。
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md
2026-09-08-desktop-uninstall-preserve-dsh-home.md: e1c2435814a4563f1fed6652d521928200d4ade7
2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md: 27fd7c8cbec360db35cadc75ff26b575629ad571
@@ -1,95 +0,0 @@
# Agent Note: Uninstall Desktop while preserving the Harness home
Status: proposed
English | [中文](2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md)
## Problem
Users need Desktop uninstallation to remove its application files and external application state while retaining the complete Harness home. Removing an application directory does not account for browser storage, cached installers, or native updater state. Browser storage also contains unsent drafts and UI preferences, so removing it changes more than disk cache usage.
The [packaging configuration](../../../../apps/desktop/electron-builder.config.mjs), [desktop entry](../../../../apps/desktop/src/main.ts), and [update coordinator](../../../../apps/desktop/src/update-coordinator.ts) are the inspection inputs. Source inspection identifies cleanup candidates; installed-artifact observation must establish the complete supported inventory before implementation is accepted. No uninstall experiment has yet established an exhaustive Windows or macOS inventory.
## Proposal
Provide an uninstall operation whose successful result leaves the complete default `~/.dsh` and any configured `DSH_HOME` intact, removes Desktop-owned files outside those homes, and removes the selected application installation. Preserve plugin files, package-manager state, sessions, settings, and credentials within the retained homes. Do not launch or repair the Desktop profile during cleanup.
Windows Control Panel and Settings uninstallation will run this operation through NSIS. macOS will expose a localized **Uninstall and keep Harness data** action backed by a signed cleanup helper. Finder drag-to-Trash alone cannot invoke this operation; document that limitation and provide the same helper as a signed standalone uninstaller for users who already removed the application. A plain drag operation must not be advertised as complete cleanup.
User-created projects, separately installed CLI software, user-saved downloads and exports, arbitrary third-party plugin/tool outputs, and OS-maintained audit/security records are outside application cleanup. Do not search the disk for names containing `dsh` or `DeepSeek`. The success promise concerns application-owned state, not deletion of user work or erasure of OS history.
### 1. Establish ownership and actual paths
Use disposable Windows and macOS accounts to capture filesystem and relevant registration differences across installation, first launch, draft editing, plugin installation, update download, update installation, and uninstallation. Inspect the packaged `package.json`, macOS `Info.plist`, `app-update.yml`, and runtime `app.getPath()` values. Include failed/interrupted updates. Record discovered owners and cleanup locations beside the desktop implementation, with fixture evidence rather than a dynamically accepted list of arbitrary absolute paths.
| Candidate | Windows | macOS | Required treatment |
|---|---|---|---|
| Electron user/session data | Runtime `userData` and `sessionData`, normally under `%APPDATA%` | Runtime `userData` and `sessionData`, normally under `~/Library/Application Support` | Remove browser storage, preferences, cache, and `.updaterId`; disclose loss of unsent drafts. |
| Installer/update cache | `%LOCALAPPDATA%/<updaterCacheDirName>` | `~/Library/Caches/<updaterCacheDirName>` | Remove cached installers, ZIPs, blockmaps, pending downloads, and metadata; include the NSIS installer copy created during installation. |
| Native updater state | Any additional paths proved by the installed NSIS flow | App-specific Squirrel and `<appId>.ShipIt` cache/state/log directories | Stop the owned updater job and remove its remaining state after it exits. |
| Other native application state | App-owned files/registrations proved by observation | App-specific preferences, saved state, logs, or diagnostics proved by observation | Add only established ownership; an Electron path API alone does not prove a file is created. |
| Application and integration | Installation directory, installed shortcuts, installation/uninstallation registration | Selected `.app` and helper-owned integration | Use the native uninstall flow and validate the selected installation identity. |
| Harness home | Default `.dsh` and configured `DSH_HOME` | Default `.dsh` and configured `DSH_HOME` | Preserve the complete trees, including Desktop-only subdirectories. |
Create one small application-owned cleanup inventory, consumed by packaging and cleanup implementations. Derive identity and updater cache names from the release metadata. The same inventory must explain test expectations. Verify any existing distributed release before adding a legacy path; do not introduce migrations for unpublished development layouts.
### 2. Implement shared preservation and cleanup rules
Resolve targets for the installation owner, not the elevated administrator's home. Preserve the default home even when an override is active, and protect configured homes recorded by Desktop as well as the current override. Validate any persisted record at this file boundary; it may protect a location but may never authorize deletion of an arbitrary location.
Reject deletion of a protected home, its ancestor, or its contents. Account for case-insensitive paths, symlinks, Windows junctions/reparse points, and path replacement during cleanup. Do not follow a link into another tree. An installation or cache location containing a protected home is a conflict: stop before recursive removal and report the remaining path. Keep these checks in the actual native removal path, not only in a preview.
Stop new windows, updates, and plugin mutations; finish or safely stop active operations; stop the owned Host/process tree; then wait for exit and released files before removal. Do not kill processes solely by executable name. Perform an ownership/path preflight before destructive steps. Treat missing targets as success, and support retry after partial cleanup. Report locked or inaccessible paths explicitly; a queued reboot deletion or partial result is not complete cleanup.
Keep `.dsh` free of cleanup journals or receipts. Any temporary helper/state must live in a private temporary directory, avoid executable-search-path lookup, and have a bounded self-cleanup path. Verify that cleanup does not recreate Electron user data after it has been removed. Cancellation before removal leaves the installation usable.
### 3. Windows implementation
Keep the assisted NSIS installer (`oneClick: false`). Extend its custom uninstall hooks and removal ordering rather than replacing the complete installer. Do not depend on `deleteAppDataOnUninstall` to cover updater caches, custom paths, or protected homes. Apply preservation checks before the default installation-directory removal as well as before extra cleanup.
Normal Control Panel, Settings, direct uninstaller, and silent uninstall must share the same cleanup rules and useful exit status. Automatic update/replacement must skip destructive user-state cleanup; validate the actual electron-updater flags and NSIS update condition with a real upgrade. A failed upgrade must retain browser data and `.dsh`.
Per-user uninstall cleans that installation owner's Desktop state. All-user uninstall must identify each affected local user profile and clean its Desktop-owned state while preserving every Harness home, under the required Windows privileges. Do not resolve all users through one `$APPDATA` expansion. If an affected profile is inaccessible or another installation still owns a shared path, report that condition instead of deleting shared state or claiming complete cleanup. Include this distinction in the uninstall UI and silent result.
Let NSIS remove the application, its installed shortcuts, and installation registration. Explicitly remove the application-owned cache/user-data inventory, including the cached copy of the installer. Preserve existing signing requirements for installer, uninstaller, and any added executable. Add a build-time check that custom hooks and the cleanup inventory are actually packaged.
### 4. macOS implementation
Add the localized uninstall action to the Electron-owned menu/recovery UI so it remains available when the Host cannot boot. Before proceeding, show that `.dsh` is retained and unsent drafts/UI preferences outside it are deleted. Complete cleanup must include removal of the selected application bundle; if only moving it to Trash, report that disk space remains until Trash is emptied.
Use a signed helper that can finish after Electron and the Host exit. Verify bundle identity and the selected path before removing it; handle `/Applications`, `~/Applications`, and a relocated writable bundle. Obtain authorization only for paths requiring it. Coordinate with Squirrel/ShipIt, remove only this application's updater jobs/state, and delete the discovered external application directories and preferences. Include helper staging files in completion verification.
Package the same cleanup implementation as a signed, notarized standalone uninstaller for the already-deleted-app case. It must not require the removed bundle or a working `.dsh` profile. A read-only mounted DMG is not an installed writable application; provide an actionable result rather than attempting to alter it. For a shared application installation, account for affected users and privileges as on Windows; report any state that could not be cleaned.
### 5. Verification and documentation
Add focused tests for target ownership, protected-home overlap, link/reparse-point traversal, identity mismatch, arbitrary-path rejection, custom/absent `DSH_HOME`, another installation sharing data, and inaccessible profiles. Exercise cancellation, active Host/update/plugin processes, interruption between removals, retries, and self-cleanup without using the developer's real home. Follow the [CI reliability workflow](../../../skills/dsh-ci-test-reliability/SKILL.md) when implementing process and filesystem tests.
Run installed-artifact e2e checks on Windows and both shipped macOS architectures. Cover first installation without updates, one downloaded/installed update, failed update, per-user/shared installation, relocated application, and standalone macOS cleanup after drag deletion. Compare filesystem/registration inventories independently of the uninstaller's success response. Hash retained `.dsh` content after the app and CLI are quiescent and before removal; require identical content, paths, and link entries afterward. Do not require preserved runtime links to resolve after their application target is removed.
Add owner-local expected output for uninstall UI and failure results. Add a keyless recorded-session scenario proving an existing conversation survives uninstall/reinstall; keep purely installer/UI expectations outside the top-level Session tree. Record the required real-server/model GUI GIF for the implementation PR. Update the [Desktop README](../../../../apps/desktop/README.md), its Chinese counterpart, user instructions, locale dictionaries, JSDoc, and this proposal's lifecycle when implementation ships. Select focused checks through [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md); signed packaging and real OS uninstall evidence are required, not replaceable by unit tests.
## Alternatives considered
**Keep the default uninstallers.** They do not establish removal of application-owned state outside the application directory and therefore do not meet the requested outcome.
**Move all Electron and update data under `.dsh`.** This can simplify file placement but preserves caches and browser state instead of cleaning them, and does not by itself cover Squirrel/native state or existing external files. It is not the cleanup solution proposed here.
**Detect Finder deletion with a permanent background watcher.** This adds a resident component that itself needs removal and cannot reliably cover deletion while it is stopped. Use an explicit uninstall operation.
**Delete `.dsh/desktop` or `.dsh/profiles/desktop`.** These paths contain Desktop-specific state, but the requested retention rule preserves the whole Harness home rather than only conversations.
## Acceptance criteria
- A successful supported uninstall removes the selected application, its owned registrations, and every external application-owned path established by the installed-artifact inventory; no cleanup helper or updater process remains.
- Default and configured Harness homes retain identical content and links after quiescence. User projects and independent CLI installations remain intact.
- Windows Control Panel/Settings and silent uninstall satisfy the same retention rule. An actual automatic upgrade retains browser state and Harness data.
- macOS's explicit and standalone uninstallers satisfy the cleanup rule. Documentation accurately distinguishes them from Finder drag deletion and from merely moving files to Trash.
- Every unremoved owned path is reported with a retry/recovery action. Unavailable privileges, an inaccessible user profile, and shared ownership never produce a false full-success result.
- Fresh install and uninstall/reinstall tests prove no untracked outside-home application state survives and that retained conversations can be reopened. Native OS inventories, not only mocked API calls, establish completion.
## Risks
Deleting browser storage loses unsent drafts and UI preferences. Whole-home preservation deliberately retains Desktop plugins, pnpm state, and possibly dangling links until reinstall repairs them. Custom data paths and shared installations can prevent complete removal without additional privileges or path relocation; the operation must report that limitation rather than violate retention.
OS-owned security/audit history and user-created files cannot be covered by a promise of erasing all traces. The [Desktop packaging decision](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled runtime/external plugin decision](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md) remain independent authorities for packaging and data ownership; this proposal supersedes neither and archives no active note.
@@ -1,95 +0,0 @@
# Agent Note: 卸载 Desktop 并保留 Harness 主目录
Status: proposed
[English](2026-09-08-desktop-uninstall-preserve-dsh-home.md) | 中文
## 问题
用户需要 Desktop 卸载删除应用文件及外部应用状态,同时保留完整的 Harness 主目录。删除应用目录并不能覆盖浏览器存储、安装器缓存或原生更新器状态。浏览器存储还包含未发送草稿和界面偏好,因此清理它不只是释放磁盘缓存。
[打包配置](../../../../apps/desktop/electron-builder.config.mjs)、[桌面入口](../../../../apps/desktop/src/main.ts)和[更新协调器](../../../../apps/desktop/src/update-coordinator.ts)是代码检查依据。源码检查用于识别清理候选项;实现验收前,必须通过已安装产物的观察确定受支持的完整清单。目前尚未通过卸载实验建立完整的 Windows 或 macOS 清单。
## 提案
提供卸载操作:成功后保留完整的默认 `~/.dsh` 和已配置的 `DSH_HOME`,删除这些主目录之外由 Desktop 拥有的文件,并移除选定的应用安装。保留主目录内的插件文件、包管理器状态、会话、设置和凭据。清理期间不得启动或修复 Desktop profile。
Windows 控制面板和设置中的卸载将通过 NSIS 执行此操作。macOS 将提供本地化的**卸载并保留 Harness 数据**操作,由签名的清理辅助程序完成;单纯通过 Finder 拖进废纸篓不能调用此操作,文档必须说明这一限制,并将同一辅助程序作为签名的独立卸载器提供给已经删除应用的用户。不得把单纯拖拽宣传为完整清理。
用户创建的项目、单独安装的 CLI(命令行界面)软件、用户保存的下载文件和导出文件、任意第三方插件或工具的输出,以及操作系统维护的审计和安全记录,不属于应用清理范围。不得按文件名包含 `dsh` 或 `DeepSeek` 全盘搜索。成功承诺针对应用拥有的状态,不包括删除用户工作成果或抹除系统历史。
### 1. 确定归属和实际路径
使用一次性 Windows 和 macOS 账户,记录安装、首次启动、编辑草稿、安装插件、下载更新、安装更新及卸载前后的文件系统和相关注册信息差异。检查打包后的 `package.json`、macOS `Info.plist`、`app-update.yml` 和运行时 `app.getPath()` 值。覆盖更新失败和中断。将发现的归属与清理位置记录在桌面实现附近,提供 fixture(测试前置数据)证据,不采用动态接收任意绝对路径的清单。
| 候选项 | Windows | macOS | 处理要求 |
|---|---|---|---|
| Electron 用户及会话数据 | 运行时 `userData` 和 `sessionData`,通常位于 `%APPDATA%` 下 | 运行时 `userData` 和 `sessionData`,通常位于 `~/Library/Application Support` 下 | 删除浏览器存储、偏好、缓存和 `.updaterId`;说明未发送草稿会丢失。 |
| 安装器及更新缓存 | `%LOCALAPPDATA%/<updaterCacheDirName>` | `~/Library/Caches/<updaterCacheDirName>` | 删除安装器副本、ZIP、blockmap、待处理下载和元数据;包括 NSIS 安装期间创建的安装器缓存副本。 |
| 原生更新器状态 | 通过实际安装的 NSIS 流程证实的其他路径 | 应用专属的 Squirrel 和 `<appId>.ShipIt` 缓存、状态及日志目录 | 停止所属更新任务,等待退出后删除残留状态。 |
| 其他原生应用状态 | 观察证实由应用拥有的文件和注册信息 | 观察证实的应用专属偏好、窗口恢复状态、日志或诊断数据 | 只加入归属已确定的项目;存在 Electron 路径 API 不足以证明文件会产生。 |
| 应用及系统集成 | 安装目录、安装的快捷方式、安装和卸载注册信息 | 选定的 `.app` 及辅助程序拥有的系统集成 | 使用原生卸载流程,并验证选定安装的身份。 |
| Harness 主目录 | 默认 `.dsh` 和已配置的 `DSH_HOME` | 默认 `.dsh` 和已配置的 `DSH_HOME` | 保留完整目录树,包括 Desktop 专属子目录。 |
建立一份精简的应用清理清单,由打包和清理实现共同使用。应用身份与更新缓存名称从发布元数据推导。同一清单必须对应测试期望。加入旧路径前先核查实际已分发版本,不为未发布的开发布局增加迁移。
### 2. 实现共用的保留与清理规则
按安装所属用户解析目标,不能使用提权管理员的主目录。即使覆盖路径已启用,也保留默认主目录;同时保护 Desktop 已记录的配置主目录和当前覆盖路径。持久化记录在文件边界进行校验;记录可以保护位置,但绝不能授权删除任意位置。
拒绝删除受保护主目录、其祖先目录或其内容。处理路径大小写不敏感、符号链接、Windows junction/reparse point,以及清理期间路径被替换的情况。不得沿链接进入其他目录树。如果安装或缓存目录包含受保护主目录,则视为冲突:递归删除前停止,并报告剩余路径。这些检查必须位于实际原生删除流程中,不能只放在预览阶段。
阻止新窗口、更新和插件变更;完成或安全停止正在进行的操作;停止所属 Host 和进程树;等待进程退出并释放文件后再删除。不得仅凭可执行文件名终止进程。破坏性步骤前先检查归属和路径。目标不存在视为成功,部分清理后允许重试。明确报告被占用或无法访问的路径;已安排重启后删除或部分完成,不等于完整清理。
不得在 `.dsh` 写入清理日志或回执。临时辅助程序和状态必须位于私有临时目录,避免通过可执行文件搜索路径寻找程序,并具有有界的自行清理流程。验证清理不会在删除后重新创建 Electron 用户数据。删除开始前取消操作应保持安装可用。
### 3. Windows 实现
保留向导式 NSIS 安装器(`oneClick: false`)。扩展自定义卸载钩子和删除顺序,不替换整个安装器。不得依赖 `deleteAppDataOnUninstall` 覆盖更新缓存、自定义路径或主目录保护。在默认安装目录删除和额外清理前都应用保留检查。
正常控制面板、设置、直接运行卸载器和静默卸载必须共用清理规则,并返回有用的退出状态。自动更新或替换安装必须跳过破坏性的用户状态清理;通过实际升级验证 electron-updater 参数和 NSIS 更新条件。升级失败必须保留浏览器数据和 `.dsh`。
按用户安装的卸载清理该安装所属用户的 Desktop 状态。全用户卸载必须识别每个受影响的本地用户配置目录,在具备所需 Windows 权限的情况下清理其中由 Desktop 拥有的状态,并保留每个 Harness 主目录。不能用一次 `$APPDATA` 展开代表所有用户。如果受影响的用户目录无法访问,或另一个安装仍拥有共享路径,应报告该情况,不得删除共享状态或宣称完整清理。卸载界面和静默执行结果都要体现这一区别。
由 NSIS 删除应用、安装的快捷方式和安装注册信息。显式删除清单中的应用缓存和用户数据,包括缓存的安装器副本。维持安装器、卸载器及新增可执行文件的签名要求。增加构建期检查,证明自定义钩子和清理清单确实进入产物。
### 4. macOS 实现
在 Electron 拥有的菜单和恢复界面加入本地化卸载操作,保证 Host 无法启动时仍可使用。执行前说明保留 `.dsh`,并删除主目录外的未发送草稿和界面偏好。完整清理必须包括移除选定应用包;如果只是移入废纸篓,应说明清空废纸篓前仍占用磁盘。
使用签名的辅助程序,在 Electron 和 Host 退出后完成清理。删除前验证应用包身份和选定路径;覆盖 `/Applications`、`~/Applications` 及移动后的可写应用包。仅对需要权限的路径请求授权。协调 Squirrel/ShipIt,只移除本应用的更新任务和状态,再删除已识别的外部应用目录及偏好。完成验证必须包括辅助程序的临时文件。
把同一清理实现打包为签名、公证的独立卸载器,支持应用已被删除的情况。它不能依赖已删除的应用包或可启动的 `.dsh` profile。只读挂载的 DMG 不属于已安装的可写应用;应给出可操作的结果,不能尝试修改它。共享应用安装和 Windows 一样需要处理受影响用户及权限,并报告未能清理的状态。
### 5. 验证和文档
增加针对性测试,覆盖目标归属、受保护主目录重叠、链接和 reparse point 遍历、身份不匹配、任意路径拒绝、自定义或未设置的 `DSH_HOME`、另一安装共享数据,以及无法访问的用户配置目录。覆盖取消、活跃 Host/更新/插件进程、删除步骤间中断、重试及自行清理,禁止使用开发者真实主目录。实现进程和文件系统测试时遵循 [CI 可靠性工作流](../../../skills/dsh-ci-test-reliability/SKILL.md)。
在 Windows 和两个已发布 macOS 架构上执行已安装产物的 e2e 检查。覆盖首次安装且未更新、下载并安装一次更新、更新失败、按用户或共享安装、移动应用,以及拖拽删除后的独立 macOS 清理。独立比较文件系统和注册信息清单,不以卸载器的成功响应代替验证。应用和 CLI 静止后、删除前对保留的 `.dsh` 内容计算哈希;要求删除后内容、路径和链接条目完全一致。应用目标被删除后,不要求保留的运行时链接仍可解析。
为卸载界面和失败结果增加归属模块旁的预期输出。增加无密钥的录制会话场景,证明已有会话能跨卸载和重装保留;纯安装器或界面预期不放入顶层 Session 树。为实现 PR(Pull Request)录制规则要求的真实服务器和模型 GUI GIF。实现交付时更新 [Desktop README](../../../../apps/desktop/README.zh.md)、英文对应文档、用户说明、语言字典、JSDoc 和本提案的生命周期。通过 [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 选择针对性检查;签名打包和真实操作系统卸载证据为必需项,不能由单元测试替代。
## 考虑过的替代方案
**维持默认卸载器。** 它们不能证明应用目录之外的应用状态已被移除,因此不满足要求。
**把全部 Electron 和更新数据移到 `.dsh`。** 这能简化文件位置,但会保留缓存和浏览器状态而不是清理它们,而且自身不能覆盖 Squirrel/原生状态或已有的外部文件。本提案不采用它作为清理方案。
**通过常驻后台监视器检测 Finder 删除。** 这会增加一个本身也需要卸载的常驻组件,并且不能可靠覆盖监视器停止期间发生的删除。采用显式卸载操作。
**删除 `.dsh/desktop` 或 `.dsh/profiles/desktop`。** 这些路径包含 Desktop 专属状态,但要求保留的是整个 Harness 主目录,而不只是会话。
## 验收标准
- 受支持的卸载成功后,选定应用、它拥有的注册信息,以及已安装产物清单确定的每个外部应用路径均已移除;没有清理辅助程序或更新器进程残留。
- 静止后,默认和已配置 Harness 主目录的内容及链接完全保留。用户项目和独立 CLI 安装保持完整。
- Windows 控制面板、设置及静默卸载满足同一保留规则。实际自动升级保留浏览器状态和 Harness 数据。
- macOS 显式操作和独立卸载器满足清理规则。文档准确区分它们与 Finder 拖拽删除,以及仅移入废纸篓的情况。
- 每个未移除的所属路径都报告重试或恢复方式。权限不足、用户配置目录不可访问和共享归属均不得产生虚假的完整成功结果。
- 首次安装及卸载重装测试证明,没有未纳入清单的主目录外应用状态残留,且能重新打开保留的会话。通过原生操作系统清单验证完成,不能只依赖模拟 API 调用。
## 风险
删除浏览器存储会丢失未发送草稿和界面偏好。完整保留主目录意味着有意保留 Desktop 插件、pnpm 状态,以及可能在重装修复前悬空的链接。自定义数据路径和共享安装可能需要额外权限或移动路径才能完整删除;操作必须报告限制,不能违反保留规则。
操作系统拥有的安全和审计历史,以及用户创建的文件,不能被“抹除所有痕迹”的承诺覆盖。[Desktop 打包决策](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时与外部插件决策](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)继续分别拥有打包和数据归属规则;本提案不取代这两份记录,也不归档任何活跃记录。
+2
View File
@@ -38,6 +38,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`@browserbasehq/stagehand`](https://github.com/browserbase/stagehand) | MIT |
| [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit) | MPL-2.0 |
| [`@earendil-works/pi-ai`](https://github.com/earendil-works/pi) | MIT |
| [`@eslint-community/regexpp`](https://github.com/eslint-community/regexpp) | MIT |
| [`@fortune-sheet/core`](https://github.com/ruilisi/fortune-sheet) | MIT |
| [`@fortune-sheet/react`](https://github.com/ruilisi/fortune-sheet) | MIT |
| [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT |
@@ -71,6 +72,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`@xterm/xterm`](https://github.com/xtermjs/xterm.js) | MIT |
| [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
| [`acorn`](https://github.com/acornjs/acorn) | MIT |
| [`ajv`](https://github.com/ajv-validator/ajv) | MIT |
| [`anser`](https://github.com/IonicaBizau/anser) | MIT |
| [`buffer`](https://github.com/feross/buffer) | MIT |
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md
README.md: 46c6a66336839db443a825c66ebfd71dcce398af
README.zh.md: 14838114b04977e7668e33479d1ba84a8ddc8434
README.md: 4eb113a6155433cb69f949d9e461ccfbd0142e7c
README.zh.md: 6a0e8a38a70d2ad551b4e91619545b3def09f2ec
+1 -1
View File
@@ -43,7 +43,7 @@ The tree composes over an empty root:
Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`, `@deepseek-ai/dsh-sdk-app`, `@deepseek-ai/dsh-sdk-minimal`, `@deepseek-ai/dsh-acp-app`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins.
Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it.
Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. `--dump-config-schema` imports the composed tree's declared plugin schemas and prints JSON Schema for entries and patches instead of configuration values; read the [schema-dump safety and scope](reference/README.md#config-schema-dump) before inspecting untrusted plugins.
The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and source execution. The [startup and reload failure table](../../packages/boot/app-boot/README.md#startup-and-reload-failures) compares optional and required plugin failures with configuration HMR.
+1 -1
View File
@@ -43,7 +43,7 @@ profile 目录包含一个 `package.json`,其中记录树外插件依赖,以
`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`、`@deepseek-ai/dsh-sdk-app`、`@deepseek-ai/dsh-sdk-minimal`、`@deepseek-ai/dsh-acp-app`),再从 profile 自身的 `node_modules` 解析;pnpm 会将树外插件安装到该目录。
使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。
使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。`--dump-config-schema` 会导入组合树中插件声明的 schema,并打印描述 entry 与 patch 的 JSON Schema,而不是配置值;检查不受信任的插件前,请阅读 [schema dump 的安全性与范围](reference/README.zh.md#config-schema-dump)。
层的确切优先级、flag、关闭行为、部署默认值和源码执行方式,以 [CLI 行为参考](reference/README.zh.md)为准。[启动与重载失败表](../../packages/boot/app-boot/README.zh.md#startup-and-reload-failures)对比 optional、required 插件启动失败与配置 HMR 的行为。
+1
View File
@@ -144,6 +144,7 @@
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@types/js-yaml": "^4.0.9",
"@types/ws": "8.18.1",
"ajv": "^8.20.0",
"execa": "^10.0.0",
"ws": "8.21.0"
},
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md
README.md: 33465e8c96df9cbfbecc038616caf297146ec971
README.zh.md: d25d27851ca789a3db358157368b97c3669ed79d
README.md: 094d8ba5bfaa851429adbfa872105550e8b63fe2
README.zh.md: 2bd6abde1cb854ec9598f8f7cc10cc0a7538d2f9
+18 -1
View File
@@ -14,7 +14,7 @@ The `web`, `headless`, `sdk`, `sdk-minimal`, and `acp` profiles auto-initialize
`dsh --profile <name> --from-default-profile <template>` initializes a new custom target from one of those five shipped templates before boot. The target name cannot be a shipped profile name, and its complete profile directory must not exist. The launcher claims that directory exclusively, so residual files and another concurrent creator are rejected without modification. It copies the template's current bundle list into a new manifest with empty dependencies and an empty user patch. It does not read the local profile named by `<template>`, copy its dependencies or patch, or persist an inheritance field; later template-list changes do not rewrite the new profile. The in-box bundles named by that copied list still resolve from the current dsh installation. A successful initialization adds no launcher output.
An existing profile rejects `--from-default-profile` without changing or booting it; omit the option to use it. A residual target directory is also preserved and requires a different profile name. An unknown template or a shipped target name fails before creating the target. Unknown-template diagnostics name the valid templates. Initialization is committed before bundle resolution and application boot, so a later failure leaves the new profile on disk and the retry omits the creation option. `--dump-config` and `--dump-default-config` accept the option, initialize the target, print the requested tree, and do not boot it.
An existing profile rejects `--from-default-profile` without changing or booting it; omit the option to use it. A residual target directory is also preserved and requires a different profile name. An unknown template or a shipped target name fails before creating the target. Unknown-template diagnostics name the valid templates. Initialization is committed before bundle resolution and application boot, so a later failure leaves the new profile on disk and the retry omits the creation option. All three config-dump modes accept the option and initialize the target without booting it.
```sh
dsh rescue --from-default-profile web
@@ -50,6 +50,23 @@ dsh --profile web --patch ./extra.yml --dump-config
`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, relative plugin names in inserted rows resolve beside their patch file, and unmatched patch targets are reported on stderr. A dump initializes missing profile files. It never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments.
<a id="config-schema-dump"></a>
### Config schema dump
`--dump-config-schema` uses the same bundle, profile, home, and argv patch layers as `--dump-config`, including repeatable `--patch` and `--from-default-profile`. The three dump flags are mutually exclusive and reject app arguments and the reserved `desktop` profile. Successful composition produces one pretty-printed JSON Schema 2020-12 document on stdout. Its root describes the parsed entry list printed by `--dump-config`; `$defs.patchList` separately describes profile/home/CLI overlays. Keep the document's `$defs` when validating that fragment. Preparation, patch parsing/composition, or resolution-setup failures exit nonzero without a schema. Collection and projection failures retain partial output and exit 1; diagnostics also appear on stderr. This includes `partial` projections and omitted non-JSON annotations even when stdout contains a valid, useful schema. Schema-dump unmatched-target warnings do not carry layer labels; use `--dump-config` with the same layers for source-labelled warnings.
Plugin Config fields, defaults, descriptions, and supported constraints are projected from native Schemastery declarations. Ordinary fields are inline; shared Configs and recursion use `$ref`. JSON Schema defaults are annotations, not default insertion. Required fields account for whether Schemastery's nullable fallback can pass validation. Unions use `anyOf`, while native execution still selects its first successful branch. Role metadata such as `secret`, `credential-ref`, and `ms`, plus `volatile` live-update metadata, remains in `x-cordis` annotations. Invalid volatile containment is a schema-definition error; field input types do not become reference-object types. Callback validation, unsupported regex semantics, and other unprojected constraints are marked partial rather than silently discarded. Non-finite bounds and non-JSON default/presentation annotations are omitted with limitations while structural fields remain available. Object constants retain ordinary nullable-member constraints, but inherited-property comparison is marked partial. Unrepresentable defaults, unsupported intersections, and unresolved recursive-default dependencies retain unknown omission behavior; native validation is still required.
Parse YAML with the Cordis entry-list dialect: `!!js` scalars become inert `{ "__jsExpr": "..." }` markers. Ordinary Config values and entry `disabled` accept these markers without evaluating their results. Group lists and Include fields stay literal. Entry ids are optional; non-insert patches without a nonempty id are accepted as no-ops with a Loader warning. A disabled entry may omit required Config unless `group: true` forces activation; a disabled expression also leaves omission undecided until runtime. Supplied Config values are still validated. `disabled` accepts booleans, null, and expression markers; other truthy values that the Loader coerces are rejected. Children of a disabled group or include without `group: true` are not validated because the Loader never creates them. Children under a disabled `group: true` row and patch insertions into a disabled group are validated as enabled, although the Loader keeps ancestor-disabled children uninitialized. Known root-tree targets validate whole Config replacements, not deep partial objects. Include-local ids and sequence-dependent targets introduced or changed by earlier patches are not inferred. Unknown plugin names remain open, and differing resolutions of one name use a union with an ambiguity diagnostic.
The root `x-cordis` annotation contains `profile`, `complete`, `entries`, `diagnostics`, and `patchSchema`. Entries retain traversal order with `path`, optional `id`, `name`, `status`, and `configRef`; statuses are `schema`, `partial`, `absent`, `unsupported`, or `error`. Optional `tree: "group" | "include"` identifies native carriers: they can have `status: "absent"` because no Config is exported, while `configRef` points to the Loader structure definition. Malformed inspected rows receive positioned errors without discarding valid siblings; `name` is omitted when no literal name exists. Invalid list containers or include-patch composition failures produce carrier-level diagnostics; failed composition leaves that include's children unavailable rather than reporting unpatched or filtered children as final. A disabled carrier without `group: true` may omit its config and is recorded as a childless tree. Projection limitations are repeated at every entry sharing that Config. An absent Config means unknown fields, not forbidden configuration. Discovery paths append `/config/<index>` for group children and `/include/<index>` for included children; the latter are not JSON pointers into the root dump. `complete` is false for any error diagnostic, any `partial`/`unsupported`/`error` entry, or one plugin name mapping to multiple Config definitions. This reference covers declarations, including disabled rows: their import or include failures can make a bootable profile incomplete. Runtime-generated preset/client trees and plugin startup checks are outside this reference; conversely, `complete` does not guarantee successful boot.
Earlier union branches that may alter inputs and dictionary keys that may rename/collide require relaxed validation. Lazy metadata propagation may affect shared nodes beyond the current Config, so that Config retains available declaration details alongside an unrestricted alternative rather than simulating native mutation. These cases are partial and require native validation.
Collection imports trusted modules and may invoke Config getters and lazy builders, but never applies plugins, executes transform callbacks, or evaluates configuration expressions. Imports can block or retain process handles before or after output; automated callers should enforce an external timeout. The dump does not force-exit or own disposal of import-time resources. Ordinary import/builder stdout is routed to stderr; direct file-descriptor writes are not intercepted. Profile preparation retains YAML dump initialization writes. Only schema declarations are emitted, not actual configured values; declared defaults and raw plugin errors can still contain sensitive data. Review output before sharing. See [app-boot](../../../packages/boot/app-boot/README.md) for the collector API.
Treat the output as a regenerated, pre-stable reference, not a versioned persisted catalog. `$schema` identifies the JSON Schema validation dialect; `x-cordis` follows dsh releases. Regenerate after changing dsh or plugins, and follow `$ref` and `configRef` rather than hard-coding definition names, ordering, or text. Incompatible dialect changes select a new `$schema`; annotation changes do not use Session-format migrations.
<a id="startup-diagnostics"></a>
## Startup diagnostics
+18 -1
View File
@@ -16,7 +16,7 @@
`dsh --profile <name> --from-default-profile <template>` 会在启动前,从上述五个随附模板之一初始化新的自定义目标。目标名称不能是随附 profile 名称,并且完整的目标 profile 目录必须不存在。launcher 会以独占方式领取该目录,因此残留文件和另一个并发创建者都会在不作修改的情况下被拒绝。它把模板当前的组合包列表复制进一份依赖为空、用户 patch 为空的新 manifest。它不会读取 `<template>` 指定的本地同名 profile,不会复制其依赖或 patch,也不会持久化继承字段;模板列表之后的变化不会改写新 profile。复制列表中指名的内置组合包仍从当前 dsh 安装目录解析。初始化成功不会增加 launcher 输出。
profile 已经存在时,`--from-default-profile` 会被拒绝,且不会修改或启动它;去掉该选项即可使用它。残留的目标目录同样会被原样保留,此时必须改用另一个 profile 名称。未知模板或随附目标名称会在创建目标之前失败;未知模板的诊断会列出有效模板。初始化在组合包解析和应用启动之前提交,因此后续失败仍会把新 profile 留在磁盘上,重试时需要去掉创建选项。`--dump-config` 和 `--dump-default-config` 接受该选项:它们初始化目标并打印所请求的配置树,但不启动应用。
profile 已经存在时,`--from-default-profile` 会被拒绝,且不会修改或启动它;去掉该选项即可使用它。残留的目标目录同样会被原样保留,此时必须改用另一个 profile 名称。未知模板或随附目标名称会在创建目标之前失败;未知模板的诊断会列出有效模板。初始化在组合包解析和应用启动之前提交,因此后续失败仍会把新 profile 留在磁盘上,重试时需要去掉创建选项。三种配置 dump 模式都接受该选项,并在不启动应用的情况下初始化目标。
```sh
dsh rescue --from-default-profile web
@@ -52,6 +52,23 @@ dsh --profile web --patch ./extra.yml --dump-config
`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,插入行中的相对插件名以各自 patch 文件所在目录解析,找不到目标的 patch 会报告到 stderr。dump 操作会初始化缺失的 profile 文件。它不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。
<a id="config-schema-dump"></a>
### 配置 schema dump
`--dump-config-schema` 使用与 `--dump-config` 相同的组合包、profile、home 和 argv patch 层,支持可重复的 `--patch` 与 `--from-default-profile`。三种 dump flag 互斥,并拒绝应用参数和保留的 `desktop` profile。组合成功后,stdout 输出一份缩进排版的 JSON Schema 2020-12 文档。根 schema 描述 `--dump-config` 输出经解析后的 entry list;`$defs.patchList` 单独描述 profile/home/CLI overlay。验证该片段时应保留文档的 `$defs`。准备、patch 解析/组合或解析器设置失败时,以非零退出码结束,不输出 schema。收集或投影失败时保留部分输出并退出 1;诊断也会输出到 stderr。即使 stdout 中的 schema 有效且可用,`partial` 投影或省略非 JSON 注释也适用此退出码。schema dump 的未匹配目标警告不包含层标签;需要来源标签时,使用相同配置层运行 `--dump-config`。
插件 Config 的字段、默认值、描述及支持的约束从原生 Schemastery 声明投影。普通字段内联,共享 Config 和递归使用 `$ref`。JSON Schema 的默认值是注释,不执行填值。必填字段会考虑 Schemastery 的 nullable fallback 能否通过验证。联合类型使用 `anyOf`,而原生执行仍选择首个成功分支。`secret`、`credential-ref`、`ms` 等 role 元信息及 `volatile` 实时更新元信息保留在 `x-cordis` 注释中。非法 volatile 嵌套属于 schema 定义错误;字段输入类型不会变成引用对象类型。回调验证、不支持的正则语义及其他未投影约束会标记为 partial,而不是静默丢弃。非有限数边界和非 JSON 默认值/展示注释会被省略并附上限制说明,结构字段仍可用。对象常量保留普通 nullable 成员约束,但继承属性的比较会标记为 partial。无法表示的默认值、不支持的交集及无法求解的递归默认值依赖保留未知的省略行为,仍需原生验证。
使用 Cordis entry-list 方言解析 YAML:`!!js` 标量变为不执行的 `{ "__jsExpr": "..." }` 标记。普通 Config 值和 entry 的 `disabled` 接受这些标记,但不求值其结果。Group 列表和 Include 字段保持字面量。entry id 可省略;缺少非空 id 的非 insert patch 被接受为无操作,并由 Loader 警告。禁用项可以省略必需的 Config,除非 `group: true` 强制激活;disabled 表达式也使省略行为留待运行时决定。已提供的 Config 值仍接受验证。`disabled` 接受布尔值、null 和表达式标记;Loader 会把其他真值强制视为禁用,但此 schema 拒绝它们。未设置 `group: true` 的禁用 group 或 include 的子项不接受验证,因为 Loader 从不创建它们。禁用的 `group: true` 行之下的子项,以及插入到禁用 group 的 patch,都按启用状态验证,尽管 Loader 不会初始化祖先被禁用的子项。已知根树目标验证完整 Config 替换,而不是深层 partial 对象。不推断 Include 内部 id,也不推断前序 patch 新增或改变的顺序相关目标。未知插件名保持开放;同名插件解析出不同 schema 时,使用联合约束并报告歧义。
根 `x-cordis` 注释包含 `profile`、`complete`、`entries`、`diagnostics` 和 `patchSchema`。配置项按遍历顺序保留 `path`、可选 `id`、`name`、`status` 和 `configRef`;状态为 `schema`、`partial`、`absent`、`unsupported` 或 `error`。可选的 `tree: "group" | "include"` 标识原生承载插件:它们可能因未导出 Config 而具有 `status: "absent"`,但 `configRef` 指向 Loader 结构定义。被检查的非法条目会获得带位置的错误,不丢弃有效的相邻条目;没有字面量名称时省略 `name`。列表容器非法或 include patch 组合失败时,在承载条目上报告诊断;组合失败后该 include 的子项不可用,不会把未应用 patch 或过滤后的子项当作最终结果。未设置 `group: true` 的禁用承载条目可以省略 config,并记录为没有子项的树。投影限制会在共享该 Config 的每个条目上重复报告。Config 缺失表示字段未知,不表示禁止配置。group 子项的发现路径追加 `/config/<index>`,include 子项追加 `/include/<index>`;后者不是根 dump 中的 JSON Pointer。存在错误诊断、`partial`/`unsupported`/`error` 条目,或同一插件名对应多个 Config 定义时,`complete` 为 false。此参考覆盖所有声明,包括禁用项:它们的导入或 include 失败可能使可启动的 profile 也被标为不完整。运行时生成的 preset/客户端树及插件启动检查不属于此参考范围;反过来,`complete` 也不保证启动成功。
可能修改输入的前序 union 分支,以及可能改名或碰撞的字典键,需要放宽验证。Lazy 元数据传播可能影响当前 Config 之外的共享节点,因此该 Config 保留可获得的声明细节,同时增加不受限制的备选项,而不模拟原生修改。这些情况标记为 partial,仍需原生验证。
收集会导入可信模块,也可能调用 Config getter 和 lazy builder,但绝不应用插件、执行 transform 回调或求值配置表达式。导入可能在输出前后阻塞或保留进程句柄;自动调用方应设置外部超时。dump 不强制退出,也不持有导入期间资源的释放职责。导入或 builder 对 stdout 的常规写入转向 stderr;直接写文件描述符不被拦截。profile 准备保留 YAML dump 的初始化写入。输出只有 schema 声明,没有实际配置值;声明的默认值和插件原始错误仍可能含敏感数据,分享前应检查。收集器 API 参见 [app-boot](../../../packages/boot/app-boot/README.zh.md)。
输出是可重新生成的 pre-stable 参考,不是单独版本化的持久化目录。`$schema` 标识 JSON Schema 验证方言;`x-cordis` 随 dsh 版本演进。更改 dsh 或插件后应重新生成,并跟随 `$ref` 和 `configRef`,而非硬编码定义名、顺序或文本。不兼容的方言变更使用新的 `$schema`;注释变更不采用 Session 格式迁移。
<a id="startup-diagnostics"></a>
## 启动诊断
+19 -4
View File
@@ -40,6 +40,15 @@ interface DumpConfigInvocation {
patches: string[]
}
/** Print declared plugin schemas without mounting the profile. */
interface DumpConfigSchemaInvocation {
mode: 'dump-config-schema'
profile: string
/** Shipped template used once to initialize a missing profile. */
fromDefaultProfile?: string | undefined
patches: string[]
}
/** Manage a profile's plugins: forward `args` to pnpm inside the profile directory. */
interface PluginInvocation {
mode: 'plugin'
@@ -49,13 +58,14 @@ interface PluginInvocation {
}
/** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */
export type DshInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation
export type DshInvocation = ProfileInvocation | DumpConfigInvocation | DumpConfigSchemaInvocation | PluginInvocation
/** Launcher flags for profile boot and configuration dumps. */
interface BootOptions {
patch?: string[]
dumpConfig?: boolean
dumpDefaultConfig?: boolean
dumpConfigSchema?: boolean
fromDefaultProfile?: string
}
@@ -102,11 +112,12 @@ function resolveBoot(program: Command, profile: string, options: BootOptions, ar
const patches = options.patch ?? []
if (patches.includes('')) program.error('error: --patch needs a path')
if (options.fromDefaultProfile === '') program.error('error: --from-default-profile needs a name')
if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) {
const dumps = [options.dumpConfig, options.dumpDefaultConfig, options.dumpConfigSchema].filter(Boolean)
if (dumps.length === 0) {
return { mode: 'profile', profile, fromDefaultProfile: options.fromDefaultProfile, patches, args }
}
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
program.error('error: --dump-config and --dump-default-config are mutually exclusive')
if (dumps.length > 1) {
program.error('error: --dump-config, --dump-default-config, and --dump-config-schema are mutually exclusive')
}
// The dump is boot-free: it never runs app command-line providers, so it
// cannot show what those flags would decide, and printing a tree that differs
@@ -114,6 +125,9 @@ function resolveBoot(program: Command, profile: string, options: BootOptions, ar
if (args.length > 0) {
program.error(`error: config dumps take no app arguments, got ${args.map(argument => JSON.stringify(argument)).join(' ')}`)
}
if (options.dumpConfigSchema === true) {
return { mode: 'dump-config-schema', profile, fromDefaultProfile: options.fromDefaultProfile, patches }
}
const defaultOnly = options.dumpDefaultConfig === true
if (defaultOnly && patches.length > 0) {
program.error('error: --dump-default-config prints the bundle layers and takes no --patch')
@@ -154,6 +168,7 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
.option('--from-default-profile <name>', 'initialize a new custom profile from a shipped profile template')
.option('--patch <path>', 'extra patch-list overlay applied after the profile layer (repeatable)', collect)
.option('--dump-config', 'print the composed profile tree and exit')
.option('--dump-config-schema', 'print JSON Schema for profile entries and patches without mounting')
.option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit')
.action((args: string[], options: BootOptions & { profile?: string }) => {
// With the app owning -h, the launcher's own help is what a bare
+5
View File
@@ -64,6 +64,11 @@ export async function runCli(): Promise<void> {
)
break
}
case 'dump-config-schema': {
const { runDumpConfigSchema } = await import('./dump-config-schema.ts')
await runDumpConfigSchema(invocation.profile, invocation.patches, invocation.fromDefaultProfile)
break
}
default:
invocation satisfies never
throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)
+48
View File
@@ -0,0 +1,48 @@
/**
* Schema-dump entry: inspect the composed profile without applying its plugins.
* Imports and lazy schema builders execute trusted module code.
* @module @deepseek-ai/dsh/dump-config-schema
*/
/* v8 ignore file -- built-bin acceptance drives schema collection and output. */
import { generateConfigSchema, type ConfigSchemaDump } from '@deepseek-ai/dsh-app-boot'
import { collectConfigDumpLayers } from './dump-config.ts'
import { INSTALL_ANCHOR, prepareProfile } from './profile-boot.ts'
const NAME = 'dsh'
/**
* Print one JSON Schema document; incomplete collection or projection sets exitCode to 1.
* Profile initialization writes match the YAML dump. Plugin imports may execute
* top-level code, but collection never applies plugins or evaluates `!!js`.
* During schema generation, ordinary stdout writes are redirected to stderr.
* @param profile - the profile name.
* @param patches - overlay paths, in argv order.
* @param fromDefaultProfile - shipped template used once to initialize a missing profile.
* @returns after collection and output, with diagnostics also written to stderr.
*/
export async function runDumpConfigSchema(
profile: string,
patches: readonly string[],
fromDefaultProfile?: string,
): Promise<void> {
const loaded = prepareProfile(profile, true, fromDefaultProfile)
const layers = collectConfigDumpLayers(loaded, false, patches)
// oxlint-disable-next-line typescript/unbound-method -- Saved only for exact restoration, never called unbound.
const stdoutWrite = process.stdout.write
let dump: ConfigSchemaDump
// Trusted module diagnostics must not precede the JSON document on stdout.
process.stdout.write = process.stderr.write.bind(process.stderr)
try {
dump = await generateConfigSchema(NAME, loaded, layers.map(layer => layer.patches), INSTALL_ANCHOR)
} finally {
process.stdout.write = stdoutWrite
}
process.stdout.write(`${JSON.stringify(dump, null, 2)}\n`)
for (const diagnostic of dump['x-cordis'].diagnostics) {
const location = diagnostic.path === undefined ? '' : ` [${diagnostic.path}]`
process.stderr.write(`${NAME}: ${diagnostic.level}:${location} ${diagnostic.message}\n`)
}
if (!dump['x-cordis'].complete) process.exitCode = 1
}
+19 -2
View File
@@ -13,6 +13,7 @@ import {
loadOverlayPatches,
renderConfigDump,
type ConfigDumpLayer,
type Profile,
} from '@deepseek-ai/dsh-app-boot'
import { homePatchPath, prepareProfile, PROFILE_ROOT_FILENAME } from './profile-boot.ts'
@@ -35,6 +36,23 @@ export function runDumpConfig(
fromDefaultProfile?: string,
): void {
const loaded = prepareProfile(profile, !defaultOnly, fromDefaultProfile)
const layers = collectConfigDumpLayers(loaded, defaultOnly, patches)
// The dump anchors on the same empty root file the boot includes.
process.stdout.write(renderConfigDump(NAME, join(loaded.dir, PROFILE_ROOT_FILENAME), layers))
}
/**
* Read dump layers in bundle, profile, home, then argv order without composing them.
* @param loaded - prepared profile and parsed bundle and profile patches.
* @param defaultOnly - omit profile, home, and argv layers without reading their files.
* @param patches - overlay paths relative to the invoking directory, in argv order.
* @returns the labeled layers shared by YAML and schema dumps.
*/
export function collectConfigDumpLayers(
loaded: Profile,
defaultOnly: boolean,
patches: readonly string[],
): ConfigDumpLayer[] {
const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({
label: layer.packageName,
patches: layer.patches,
@@ -53,7 +71,6 @@ export function runDumpConfig(
layers.push({ label: absolute, patches: loadOverlayPatches(NAME, absolute) })
}
}
// The dump anchors on the same empty root file the boot includes.
process.stdout.write(renderConfigDump(NAME, join(loaded.dir, PROFILE_ROOT_FILENAME), layers))
return layers
}
/* v8 ignore stop */
+39 -1
View File
@@ -61,7 +61,7 @@ describe('parseDshArgs', () => {
[], ['task', 'words'], ['--help'], ['-h'], ['web'],
['--patch', 'a.yml', '--patch', 'b.yml'],
['--from-default-profile', 'web', '--help'],
['--dump-config'], ['--dump-default-config'],
['--dump-config'], ['--dump-default-config'], ['--dump-config-schema'],
['--patch', 'a.yml', '--resume', 'id', '--patch', 'late.yml'],
['--', '--help'], ['--', '--', 'task'],
['plugin'], ['--', 'plugin'],
@@ -143,6 +143,44 @@ describe('parseDshArgs', () => {
.toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] })
})
it('routes schema dumps with ordered overlays and explicit initialization', () => {
expect(parse(['--profile', 'web', '--dump-config-schema']))
.toEqual({ mode: 'dump-config-schema', profile: 'web', patches: [] })
expect(parse(['rescue', '--from-default-profile', 'web', '--patch', 'first.yml', '--dump-config-schema', '--patch', 'second.json']))
.toEqual({
mode: 'dump-config-schema',
profile: 'rescue',
fromDefaultProfile: 'web',
patches: ['first.yml', 'second.json'],
})
expect(parse(['web', '--port', '8080', '--dump-config-schema']))
.toMatchObject({ mode: 'profile', args: ['--port', '8080', '--dump-config-schema'] })
})
it.each([
['--dump-config', '--dump-default-config'],
['--dump-config', '--dump-config-schema'],
['--dump-default-config', '--dump-config-schema'],
['--dump-config', '--dump-default-config', '--dump-config-schema'],
])('rejects competing dump flags %j', (...flags: string[]) => {
expect(exitCode(['web', ...flags])).toBe(1)
expect(exitCode(['--profile', 'web', ...flags.toReversed()])).toBe(1)
})
it.each([
['--dump-config-schema'],
['desktop', '--dump-config-schema'],
['--profile', 'Desktop', '--dump-config-schema'],
['web', '--dump-config-schema', 'task'],
['web', '--dump-config-schema', '--port', '8080'],
['web', '--dump-config-schema', '--help'],
['web', '--dump-config-schema', '--', '--patch', 'late.yml'],
['web', '--dump-config-schema', '--patch='],
['rescue', '--dump-config-schema', '--from-default-profile='],
])('rejects invalid schema dump inputs %j', (...argv: string[]) => {
expect(exitCode(argv)).toBe(1)
})
it('rejects missing profile, removed flags, and contradictory inputs', () => {
expect(exitCode([])).toBe(1)
expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed
@@ -0,0 +1,420 @@
/** Keyless schema inspection through the published CLI, with native Schemastery fixtures. */
import { existsSync, mkdtempSync, realpathSync } from 'node:fs'
import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { Ajv2020 } from 'ajv/dist/2020.js'
import * as yaml from 'js-yaml'
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
import type { ConfigSchemaDump } from '@deepseek-ai/dsh-app-boot'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import { describe, expect, it, onTestFinished } from 'vitest'
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
const builtBin = join(repoRoot, 'apps/cli/lib/bin.js')
const builtArtifactsExist = existsSync(builtBin)
if (process.env.DSH_EXAMPLE_MODE === 'lib' && !builtArtifactsExist) {
throw new Error('dsh config-schema acceptance requires built CLI artifacts in lib mode; run pnpm run build first')
}
const packageName = 'dsh-schema-acceptance-fixture'
const profileName = 'schema-acceptance'
const processTimeoutMs = 90_000
const schemaImport = "import Schema from '@deepseek-ai/schemastery'"
const forbiddenApply = `
import { writeFileSync } from 'node:fs'
import { join } from 'node:path'
export function apply() {
writeFileSync(join(process.env.DSH_HOME, 'apply-ran'), 'unexpected')
throw new Error('PLUGIN_APPLY_EXECUTED')
}
`
interface Fixture {
root: string
home: string
profile: string
}
async function createFixture(modules: Record<string, string>, patches: string): Promise<Fixture> {
const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-schema-acceptance-')))
onTestFinished(() => rm(root, { recursive: true, force: true, maxRetries: 3 }))
const home = join(root, 'home')
const profile = join(home, 'profiles', profileName)
const moduleDir = join(profile, 'node_modules', packageName)
await mkdir(moduleDir, { recursive: true })
await writeFile(join(moduleDir, 'package.json'), JSON.stringify({
name: packageName,
version: '1.0.0',
type: 'module',
exports: Object.fromEntries(Object.keys(modules).map(name => [`./${name}`, `./${name}.mjs`])),
peerDependencies: { '@deepseek-ai/schemastery': '*' },
dsh: { bundle: { patch: './cordis.patch.yml' } },
}))
for (const [name, source] of Object.entries(modules)) {
await writeFile(join(moduleDir, `${name}.mjs`), `${source}\n`)
}
await writeFile(join(moduleDir, 'cordis.patch.yml'), patches)
await writeFile(join(profile, 'package.json'), JSON.stringify({
name: 'dsh-profile-schema-acceptance',
private: true,
dependencies: { [packageName]: '1.0.0' },
dsh: { profile: { bundles: [packageName] } },
}))
await writeFile(join(profile, 'cordis.patch.yml'), '[]\n')
return { root, home, profile }
}
async function installNativeInclude(fixture: Fixture): Promise<void> {
const name = '@deepseek-ai/cordis-plugin-include'
const directory = join(fixture.profile, 'node_modules', name)
await mkdir(directory, { recursive: true })
await copyFile(join(repoRoot, 'vendor/include/lib/index.js'), join(directory, 'index.js'))
await writeFile(join(directory, 'package.json'), JSON.stringify({
name, version: '1.0.3', type: 'module', exports: './index.js',
peerDependencies: { '@deepseek-ai/cordis': '*', '@deepseek-ai/cordis-plugin-loader': '*' },
dependencies: { 'js-yaml': '*' },
}))
const path = join(fixture.profile, 'package.json')
const manifest = JSON.parse(await readFile(path, 'utf8')) as { dependencies: Record<string, string> }
manifest.dependencies[name] = '1.0.3'
await writeFile(path, JSON.stringify(manifest))
}
async function dump(
fixture: Fixture,
args: readonly string[] = [],
format: '--dump-config-schema' | '--dump-config' = '--dump-config-schema',
) {
const env = Object.fromEntries(Object.entries(process.env).filter(
(entry): entry is [string, string] => entry[1] !== undefined
&& !/KEY|SECRET|TOKEN|PASSWORD/i.test(entry[0])
&& !/^(DSH_|NODE_OPTIONS$|NODE_PATH$)/i.test(entry[0]),
))
const result = await execa(process.execPath, [
builtBin, '--profile', profileName, format, ...args,
], {
cwd: fixture.root,
env: { ...env, DSH_HOME: fixture.home, DSH_TELEMETRY_DISABLED: '1' },
extendEnv: false,
input: '',
timeout: processTimeoutMs,
killSignal: 'SIGKILL',
reject: false,
})
expect(result.timedOut, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(false)
expect(result.signal, result.stderr).toBeUndefined()
expect(existsSync(join(fixture.home, 'apply-ran'))).toBe(false)
return result
}
function parseSchema(output: string): ConfigSchemaDump {
const document = JSON.parse(output) as ConfigSchemaDump
expect(document.$schema).toBe('https://json-schema.org/draft/2020-12/schema')
const validator = new Ajv2020({ strict: false, validateFormats: false })
expect(validator.validateSchema(document), JSON.stringify(validator.errors)).toBe(true)
return document
}
const namespaceModule = `${schemaImport}
console.log('schema fixture imported')
const label = Schema.string().description('Display label').default('schema-default')
export const Config = Schema.object({
label,
alias: label,
attempts: Schema.number().min(1).max(7).default(3),
}).description('Namespace settings')
${forbiddenApply}`
const classModule = `${schemaImport}
export default class ClassPlugin {
static Config = Schema.object({ enabled: Schema.boolean().default(true) })
constructor() { throw new Error('PLUGIN_CONSTRUCTOR_EXECUTED') }
}`
describe.skipIf(!builtArtifactsExist)('dsh --dump-config-schema assembled output', () => {
it('prints native namespace and class schemas without applying plugins or evaluating !!js', async () => {
const fixture = await createFixture({
namespace: namespaceModule,
class: classModule,
absent: forbiddenApply,
}, `- insert:
- id: schema-namespace
name: ${packageName}/namespace
config:
label: PRIVATE_ACTUAL_CONFIG_VALUE
attempts: 6
expression: !!js (() => { throw new Error('CONFIG_EXPRESSION_EXECUTED') })()
- id: schema-class
name: ${packageName}/class
disabled: !!js (() => { throw new Error('DISABLED_EXPRESSION_EXECUTED') })()
- id: schema-absent
name: ${packageName}/absent
`)
const result = await dump(fixture)
expect(result.exitCode, result.stderr).toBe(0)
expect(result.stderr).toBe('schema fixture imported')
expect(result.stdout).not.toContain('PRIVATE_ACTUAL_CONFIG_VALUE')
expect(result.stdout).not.toContain('EXPRESSION_EXECUTED')
const document = parseSchema(result.stdout)
await expect(`${result.stdout}\n`).toMatchFileSnapshot('./expected/dump-config-schema.json')
const composed = await dump(fixture, [], '--dump-config')
const validator = new Ajv2020({ strict: false, validateFormats: false })
const validate = validator.compile(document)
expect(validate(yaml.load(composed.stdout, { schema: entryListSchema })), JSON.stringify(validate.errors)).toBe(true)
expect(validate([{ name: `${packageName}/namespace`, config: { attempts: 'wrong' } }])).toBe(false)
const patch = validator.compile({ $schema: document.$schema, $defs: document.$defs, $ref: '#/$defs/patchList' })
expect(patch([{ id: 'schema-class', config: { enabled: 'wrong' } }])).toBe(false)
expect(patch([{ id: 'schema-class', config: { enabled: false } }])).toBe(true)
expect(result.stdout).not.toContain('"uid"')
})
it.each([false, true])('expands canonical Include and re-export aliases with profile-local copy: %s', async (localCopy) => {
const fixture = await createFixture({
alias: "export { default } from '@deepseek-ai/cordis-plugin-include'",
}, `- insert:
- id: canonical
name: '@deepseek-ai/cordis-plugin-include'
config:
path: ./nested/plugins.yml
patches:
- insert:
- id: added
name: ./child.mjs
- id: alias
name: ${packageName}/alias
config:
path: ./never-created.yml
initial:
- id: initial
name: ${packageName}/alias
config:
path: ./nested/plugins.yml
`)
if (localCopy) await installNativeInclude(fixture)
const directory = join(fixture.profile, 'nested')
await mkdir(directory)
await writeFile(join(directory, 'child.mjs'), `${schemaImport}\nexport const Config = Schema.object({ enabled: Schema.boolean() })\n${forbiddenApply}`)
await writeFile(join(directory, 'plugins.yml'), '- id: child\n name: ./child.mjs\n')
const result = await dump(fixture)
expect(result.exitCode, result.stderr).toBe(0)
expect(result.stderr).toBe('')
const document = parseSchema(result.stdout)
expect(document['x-cordis'].complete).toBe(true)
expect(document['x-cordis'].entries.filter(entry => entry.tree === 'include')).toHaveLength(3)
expect(document['x-cordis'].entries.filter(entry => entry.status === 'schema').map(entry => entry.path))
.toEqual(['/0/include/0', '/0/include/1', '/1/include/0/include/0'])
expect(existsSync(join(fixture.profile, 'never-created.yml'))).toBe(false)
})
it('does not treat a custom marker with Include-like config as a native carrier', async () => {
const fixture = await createFixture({
carrier: `export default class CustomCarrier {
static [Symbol.for('cordis.group')] = true
constructor() { throw new Error('CUSTOM_CARRIER_EXECUTED') }
}`,
}, `- insert:
- id: custom
name: ${packageName}/carrier
config:
path: ./not-an-include.yml
initial:
- name: missing-child
`)
const result = await dump(fixture)
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain('unrecognized Loader tree carrier')
const document = parseSchema(result.stdout)
expect(document['x-cordis'].entries).toHaveLength(1)
expect(document['x-cordis'].complete).toBe(false)
expect(existsSync(join(fixture.profile, 'not-an-include.yml'))).toBe(false)
})
it('returns positioned malformed-row diagnostics without losing healthy schema entries', async () => {
const fixture = await createFixture({ class: classModule }, `- insert:
- id: before
name: ${packageName}/class
- {}
- name: 17
- id: after
name: ${packageName}/class
`)
const result = await dump(fixture)
expect(result.exitCode).toBe(1)
const document = parseSchema(result.stdout)
expect(document['x-cordis'].entries.map(entry => [entry.path, entry.status]))
.toEqual([['/0', 'schema'], ['/1', 'error'], ['/2', 'error'], ['/3', 'schema']])
expect(document['x-cordis'].entries[1]).not.toHaveProperty('name')
expect(document['x-cordis'].entries[2]).not.toHaveProperty('name')
expect(document['x-cordis'].entries[0]!.configRef).toBe(document['x-cordis'].entries[3]!.configRef)
})
it('emits partial structural schemas when native annotations are not JSON-compatible', async () => {
const fixture = await createFixture({
partial: `${schemaImport}\nexport const Config = Schema.object({ label: Schema.string().extra('default', () => 'dynamic') })\n${forbiddenApply}`,
}, `- insert:
- id: partial
name: ${packageName}/partial
`)
const result = await dump(fixture)
expect(result.exitCode).toBe(1)
const document = parseSchema(result.stdout)
expect(document['x-cordis'].entries[0]!.status).toBe('partial')
expect(document['x-cordis'].complete).toBe(false)
expect(result.stderr).toContain('annotation omitted')
const validate = new Ajv2020({ strict: false }).compile(document)
expect(validate([{ name: `${packageName}/partial`, config: { label: 'literal' } }])).toBe(true)
expect(validate([{ name: `${packageName}/partial`, config: { label: 3 } }])).toBe(false)
})
it('returns partial schemas and exit 1 for unsupported Config and throwing schema builders or imports', async () => {
const fixture = await createFixture({
unsupported: `export const Config = { type: 'object' }\n${forbiddenApply}`,
lazy: `${schemaImport}\nexport const Config = Schema.lazy(() => { throw new Error('fixture lazy schema failed') })\n${forbiddenApply}`,
broken: "throw new Error('fixture module import failed')",
recovered: classModule,
}, `- insert:
- id: schema-unsupported
name: ${packageName}/unsupported
- id: schema-lazy-error
name: ${packageName}/lazy
- id: schema-import-error
name: ${packageName}/broken
- id: schema-recovered
name: ${packageName}/recovered
`)
const result = await dump(fixture)
expect(result.exitCode, result.stderr).toBe(1)
const document = parseSchema(result.stdout)
const catalog = document['x-cordis']
expect(catalog.complete).toBe(false)
expect(catalog.entries.map(({ id, status }) => ({ id, status }))).toEqual([
{ id: 'schema-unsupported', status: 'unsupported' },
{ id: 'schema-lazy-error', status: 'error' },
{ id: 'schema-import-error', status: 'error' },
{ id: 'schema-recovered', status: 'schema' },
])
expect(catalog.entries[3]?.configRef).toBe('#/$defs/config0')
const validator = new Ajv2020({ strict: false, validateFormats: false })
const validate = validator.compile({ $schema: document.$schema, $defs: document.$defs, $ref: '#/$defs/config0' })
expect(validate({ enabled: true })).toBe(true)
expect(validate({ enabled: 'wrong' })).toBe(false)
expect(catalog.diagnostics).toEqual([
{ level: 'error', path: '/0', message: 'Config is not a native Schemastery schema' },
{ level: 'error', path: '/1', message: 'fixture lazy schema failed' },
{ level: 'error', path: '/2', message: 'fixture module import failed' },
])
expect(result.stderr.split('\n')).toEqual([
'dsh: error: [/0] Config is not a native Schemastery schema',
'dsh: error: [/1] fixture lazy schema failed',
'dsh: error: [/2] fixture module import failed',
])
})
it('marks a skipped selected bundle incomplete and exits 1', async () => {
const fixture = await createFixture({ absent: forbiddenApply }, `- insert:
- id: schema-retained
name: ${packageName}/absent
`)
const missingBundle = 'dsh-schema-acceptance-missing-bundle'
await writeFile(join(fixture.profile, 'package.json'), JSON.stringify({
private: true,
dependencies: { [packageName]: '1.0.0' },
dsh: { profile: { bundles: [packageName, missingBundle] } },
}))
const result = await dump(fixture)
expect(result.exitCode, result.stderr).toBe(1)
const catalog = parseSchema(result.stdout)['x-cordis']
expect(catalog.complete).toBe(false)
expect(catalog.entries).toEqual([
{ path: '/0', id: 'schema-retained', name: `${packageName}/absent`, status: 'absent', configRef: '#/$defs/unknownConfig' },
])
expect(catalog.diagnostics).toEqual([{
level: 'error',
message: `Selected profile bundle "${missingBundle}" could not be loaded; repair or remove its bundle selection.`,
}])
expect(result.stderr).toContain(missingBundle)
for (const diagnostic of catalog.diagnostics) expect(result.stderr).toContain(diagnostic.message)
})
it('keeps schema warnings plain and provides source-labelled warnings through the YAML dump', async () => {
const fixture = await createFixture({ absent: forbiddenApply }, `- insert:\n - id: existing\n name: ${packageName}/absent\n`)
const overlay = join(fixture.root, 'unmatched.yml')
await writeFile(overlay, '- id: missing\n disabled: true\n')
const schema = await dump(fixture, ['--patch', overlay])
expect(schema.exitCode).toBe(0)
expect(schema.stderr).toBe('dsh: warning: patch: entry "missing" not found')
expect(parseSchema(schema.stdout)['x-cordis'].diagnostics).toEqual([{ level: 'warning', message: 'patch: entry "missing" not found' }])
const config = await dump(fixture, ['--patch', overlay], '--dump-config')
expect(config.exitCode).toBe(0)
expect(config.stderr).toContain(`[${overlay}]`)
expect(config.stderr).toContain('patch: entry "missing" not found')
})
it('keeps --dump-config as boot-free YAML containing literal configured values', async () => {
const fixture = await createFixture({ absent: forbiddenApply }, `- insert:
- id: yaml-entry
name: ${packageName}/absent
config:
value: ACTUAL_YAML_VALUE
expression: !!js (() => { throw new Error('YAML_EXPRESSION_EXECUTED') })()
`)
const result = await dump(fixture, [], '--dump-config')
expect(result.exitCode, result.stderr).toBe(0)
expect(result.stderr).toBe('')
expect(result.stdout).toContain(`name: ${packageName}/absent`)
expect(result.stdout).toContain('value: ACTUAL_YAML_VALUE')
expect(result.stdout).toContain('!!js')
expect(result.stdout).toContain('YAML_EXPRESSION_EXECUTED')
expect(result.stdout).not.toContain('"$schema"')
})
it('composes --patch layers in order and resolves modules beside their profile or overlay', async () => {
const fixture = await createFixture({ absent: forbiddenApply }, `- insert:
- id: schema-bundle
name: ${packageName}/absent
`)
await writeFile(join(fixture.profile, 'local-schema.mjs'), classModule)
await writeFile(join(fixture.root, 'local-schema.mjs'), "throw new Error('WRONG_MODULE_ROOT')\n")
await writeFile(join(fixture.profile, 'cordis.patch.yml'), `- insert:
- id: schema-profile
name: ./local-schema.mjs
`)
await mkdir(join(fixture.root, 'overlays'))
await writeFile(join(fixture.root, 'overlays', 'local-schema.mjs'), forbiddenApply)
await writeFile(join(fixture.root, 'overlays', 'first.yml'), `- insert:
- id: schema-overlay
name: ./local-schema.mjs
`)
await writeFile(join(fixture.root, 'overlays', 'second.yml'), `- id: schema-overlay
config:
enabled: PRIVATE_OVERLAY_VALUE
- insert:
- id: schema-second-overlay
name: ./local-schema.mjs
`)
const result = await dump(fixture, ['--patch', 'overlays/first.yml', '--patch', 'overlays/second.yml'])
expect(result.exitCode, result.stderr).toBe(0)
expect(result.stderr).toBe('')
expect(result.stdout).not.toContain('PRIVATE_OVERLAY_VALUE')
const catalog = parseSchema(result.stdout)['x-cordis']
expect(catalog.complete).toBe(true)
expect(catalog.diagnostics).toEqual([])
expect(catalog.entries.map(({ path, id, name, status }) => ({ path, id, name, status }))).toEqual([
{ path: '/0', id: 'schema-bundle', name: `${packageName}/absent`, status: 'absent' },
{
path: '/1', id: 'schema-profile',
name: pathToFileURL(join(fixture.profile, 'local-schema.mjs')).href, status: 'schema',
},
{
path: '/2', id: 'schema-overlay',
name: pathToFileURL(join(fixture.root, 'overlays', 'local-schema.mjs')).href, status: 'absent',
},
{
path: '/3', id: 'schema-second-overlay',
name: pathToFileURL(join(fixture.root, 'overlays', 'local-schema.mjs')).href, status: 'absent',
},
])
})
})
@@ -0,0 +1,615 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Cordis configuration for profile schema-acceptance",
"description": "Describes the parsed entry-list YAML printed by --dump-config. Use $defs.patchList for a profile/home/CLI overlay. Parse !!js with the Cordis entry-list YAML dialect. Expression results, service dependencies, plugin startup checks, and sequence-dependent patch targets still require runtime validation.",
"$comment": "Bundle, profile, home, and CLI layers apply in that order. A patch config replaces the whole config. JSON Schema defaults are annotations; Schemastery validates a fallback on null/omission unless required rejects it first. Schemastery unions select the first successful branch. Root patch id constraints describe the current index, not ids introduced or changed by earlier patches. Include-local patches have a separate index. Relative plugin insertions require their source patch directory; this document does not infer future overlay locations.",
"type": "array",
"items": {
"$ref": "#/$defs/entry"
},
"$defs": {
"loaderExpression": {
"type": "object",
"properties": {
"__jsExpr": {
"type": "string"
}
},
"required": [
"__jsExpr"
],
"description": "Inert representation of a YAML !!js scalar from the Cordis entry-list parser. Its result is evaluated and validated only at runtime. Extra marker-object fields are ignored by interpolation."
},
"entryMetadata": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Entry id. Loader generates an id when an entry omits it; patches use the configured id."
},
"name": {
"type": "string",
"description": "Plugin module specifier. Inserted relative plugin paths are anchored beside their patch file."
},
"config": {},
"group": {
"type": [
"boolean",
"null"
],
"description": "Allows patch indexing and insertion into an entry-list config; does not select the plugin implementation."
},
"disabled": {
"anyOf": [
{
"type": [
"boolean",
"null"
]
},
{
"$ref": "#/$defs/loaderExpression"
}
],
"description": "Boolean or !!js expression. The Loader coerces other truthy values as disabled; this schema rejects them."
},
"inject": {
"anyOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"type": "object"
},
{
"type": "null"
}
]
},
"intercept": {
"type": [
"object",
"null"
]
},
"isolate": {
"type": [
"object",
"null"
],
"additionalProperties": {
"anyOf": [
{
"const": true
},
{
"type": "string"
}
]
}
}
}
},
"entryList": {
"type": "array",
"items": {
"$ref": "#/$defs/entry"
}
},
"patchList": {
"type": "array",
"items": {
"$ref": "#/$defs/patch"
}
},
"unknownConfig": {
"$comment": "No projected Config schema is available; this is unknown configuration, not a prohibition on fields."
},
"includeConfig": {
"type": "object",
"required": [
"path"
],
"properties": {
"path": {
"type": "string",
"description": "YAML/JSON filename resolved relative to the owning Loader tree. This field is literal, not a !!js expression."
},
"initial": {
"$ref": "#/$defs/entryList"
},
"patches": {
"type": "array",
"items": {
"$ref": "#/$defs/includePatch"
}
},
"enableLogs": {
"type": "boolean"
}
},
"description": "Native Include configuration stays literal. initial is used only when the file is absent. Included files have their own module-resolution base and patch target index."
},
"includePatch": {
"type": "object",
"allOf": [
{
"$ref": "#/$defs/entryMetadata"
}
],
"properties": {
"insert": {
"$ref": "#/$defs/entryList"
}
},
"description": "An insert appends entries, optionally inside the group identified by id. Other patches replace supplied fields; config is replaced wholesale, not deep-merged. A truthy name asserts the existing plugin name rather than renaming it. Unknown targets and non-insert patches without a nonempty id are warned and skipped."
},
"config0": {
"description": "Namespace settings",
"default": {},
"anyOf": [
{
"type": [
"object",
"null"
],
"properties": {
"label": {
"description": "Display label",
"default": "schema-default",
"anyOf": [
{
"type": [
"string",
"null"
]
},
{
"$ref": "#/$defs/loaderExpression"
}
]
},
"alias": {
"description": "Display label",
"default": "schema-default",
"anyOf": [
{
"type": [
"string",
"null"
]
},
{
"$ref": "#/$defs/loaderExpression"
}
]
},
"attempts": {
"default": 3,
"anyOf": [
{
"type": [
"number",
"null"
],
"minimum": 1,
"maximum": 7
},
{
"$ref": "#/$defs/loaderExpression"
}
]
}
}
},
{
"$ref": "#/$defs/loaderExpression"
}
]
},
"config1": {
"default": {},
"anyOf": [
{
"type": [
"object",
"null"
],
"properties": {
"enabled": {
"default": true,
"anyOf": [
{
"type": [
"boolean",
"null"
]
},
{
"$ref": "#/$defs/loaderExpression"
}
]
}
}
},
{
"$ref": "#/$defs/loaderExpression"
}
]
},
"entry": {
"type": "object",
"required": [
"name"
],
"allOf": [
{
"$ref": "#/$defs/entryMetadata"
},
{
"if": {
"properties": {
"name": {
"const": "cordis:group"
}
},
"required": [
"name"
]
},
"then": {
"if": {
"properties": {
"disabled": {
"anyOf": [
{
"const": true
},
{
"$ref": "#/$defs/loaderExpression"
}
]
},
"group": {
"not": {
"const": true
}
}
},
"required": [
"disabled"
]
},
"else": {
"properties": {
"config": {
"anyOf": [
{
"$ref": "#/$defs/entryList"
}
]
}
},
"required": [
"config"
]
}
}
},
{
"if": {
"properties": {
"name": {
"const": "cordis:include"
}
},
"required": [
"name"
]
},
"then": {
"if": {
"properties": {
"disabled": {
"anyOf": [
{
"const": true
},
{
"$ref": "#/$defs/loaderExpression"
}
]
},
"group": {
"not": {
"const": true
}
}
},
"required": [
"disabled"
]
},
"else": {
"properties": {
"config": {
"anyOf": [
{
"$ref": "#/$defs/includeConfig"
}
]
}
},
"required": [
"config"
]
}
}
},
{
"if": {
"properties": {
"name": {
"const": "dsh-schema-acceptance-fixture/namespace"
}
},
"required": [
"name"
]
},
"then": {
"properties": {
"config": {
"anyOf": [
{
"$ref": "#/$defs/config0"
}
]
}
}
}
},
{
"if": {
"properties": {
"name": {
"const": "dsh-schema-acceptance-fixture/class"
}
},
"required": [
"name"
]
},
"then": {
"properties": {
"config": {
"anyOf": [
{
"$ref": "#/$defs/config1"
}
]
}
}
}
},
{
"if": {
"properties": {
"name": {
"const": "dsh-schema-acceptance-fixture/absent"
}
},
"required": [
"name"
]
},
"then": {
"properties": {
"config": {
"anyOf": [
{
"$ref": "#/$defs/unknownConfig"
}
]
}
}
}
}
],
"description": "Loader entry. Unknown metadata and plugin names are accepted; plugin-specific validation is available only for the names collected in this profile. Distinct resolutions of one name use a union of their schemas."
},
"patch": {
"type": "object",
"allOf": [
{
"$ref": "#/$defs/entryMetadata"
},
{
"if": {
"required": [
"id"
],
"properties": {
"id": {
"const": "schema-namespace"
}
},
"allOf": [
{
"not": {
"required": [
"insert"
]
}
},
{
"anyOf": [
{
"not": {
"required": [
"name"
]
}
},
{
"properties": {
"name": {
"enum": [
"",
"dsh-schema-acceptance-fixture/namespace"
]
}
}
}
]
}
]
},
"then": {
"properties": {
"config": {
"$ref": "#/$defs/config0"
}
}
}
},
{
"if": {
"required": [
"id"
],
"properties": {
"id": {
"const": "schema-class"
}
},
"allOf": [
{
"not": {
"required": [
"insert"
]
}
},
{
"anyOf": [
{
"not": {
"required": [
"name"
]
}
},
{
"properties": {
"name": {
"enum": [
"",
"dsh-schema-acceptance-fixture/class"
]
}
}
}
]
}
]
},
"then": {
"properties": {
"config": {
"$ref": "#/$defs/config1"
}
}
}
},
{
"if": {
"required": [
"id"
],
"properties": {
"id": {
"const": "schema-absent"
}
},
"allOf": [
{
"not": {
"required": [
"insert"
]
}
},
{
"anyOf": [
{
"not": {
"required": [
"name"
]
}
},
{
"properties": {
"name": {
"enum": [
"",
"dsh-schema-acceptance-fixture/absent"
]
}
}
}
]
}
]
},
"then": {
"properties": {
"config": {
"$ref": "#/$defs/unknownConfig"
}
}
}
}
],
"properties": {
"insert": {
"$ref": "#/$defs/entryList"
}
},
"description": "An insert appends entries, optionally inside the group identified by id. Other patches replace supplied fields; config is replaced wholesale, not deep-merged. A truthy name asserts the existing plugin name rather than renaming it. Unknown targets and non-insert patches without a nonempty id are warned and skipped."
}
},
"x-cordis": {
"profile": "schema-acceptance",
"complete": true,
"entries": [
{
"path": "/0",
"id": "schema-namespace",
"name": "dsh-schema-acceptance-fixture/namespace",
"status": "schema",
"configRef": "#/$defs/config0"
},
{
"path": "/1",
"id": "schema-class",
"name": "dsh-schema-acceptance-fixture/class",
"status": "schema",
"configRef": "#/$defs/config1"
},
{
"path": "/2",
"id": "schema-absent",
"name": "dsh-schema-acceptance-fixture/absent",
"status": "absent",
"configRef": "#/$defs/unknownConfig"
}
],
"diagnostics": [],
"patchSchema": "#/$defs/patchList"
}
}
@@ -16,6 +16,8 @@ Options:
--patch <path> extra patch-list overlay applied after the
profile layer (repeatable)
--dump-config print the composed profile tree and exit
--dump-config-schema print JSON Schema for profile entries and
patches without mounting
--dump-default-config print the profile tree without its user layer
or --patch overlays and exit
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
README.md: e99f771653c62175655f12d6982c04e5f91055d1
README.zh.md: 9c7f86e5ab1ad0e42fc0fe2ea643c6ca4bed392f
README.md: 2247f79c839da78f997a80ba73420d0ab42cb926
README.zh.md: 8ddb461c44191ac936e70064f78871063db67e64
+20 -6
View File
@@ -105,9 +105,9 @@ The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented
### Release versions
Before each Desktop packaging run, first confirm the complete version string with the current user. Check the selected deployment, dsh base version, retained release records, and published objects, then propose the exact version for approval. Do not change release-family manifests or start packaging until the user confirms that version; the deployment setting alone does not authorize a version choice.
Before each Desktop packaging run, first confirm the complete version string with the current user. Check the selected deployment, dsh base version, retained release records, and published objects, then propose the exact version for approval. Do not start packaging until the user confirms that version; the deployment setting alone does not authorize a version choice.
Record the current dsh version as the base before changing any manifests. A production Desktop release uses that exact version, including any `alpha`, `beta`, or `rc` identifiers. A test release preserves the complete prerelease base and appends `.YYYYMMDD.index`; a stable base uses `-test.YYYYMMDD.index` instead.
Record the current dsh version as the base. A production Desktop release uses that exact version, including any `alpha`, `beta`, or `rc` identifiers. A test release preserves the complete prerelease base and appends `.YYYYMMDD.index`; a stable base uses `-test.YYYYMMDD.index` instead.
| dsh base | Production Desktop | Test Desktop example |
|---|---|---|
@@ -116,13 +116,23 @@ Record the current dsh version as the base before changing any manifests. A prod
| `0.1.6-rc.3` | `0.1.6-rc.3` | `0.1.6-rc.3.20260916.1` |
| `0.1.6` | `0.1.6` | `0.1.6-test.20260916.1` |
Use the actual creation date in Asia/Shanghai. For each base and date, start the index at 1 and increment after checking retained release records and published objects; never reuse a published version. Derive once from the recorded base, not from a manifest already carrying a test suffix. The final root, Desktop, bundled dsh, private Desktop Host, and other release-family manifests must all carry the same derived version. Test distribution does not publish the corresponding unsuffixed base.
Use the actual creation date in Asia/Shanghai. For each base and date, start the index at 1 and increment after checking retained release records and published objects; never reuse a published version. Test distribution does not publish the corresponding unsuffixed base.
Pass the confirmed version to the packaging command as `--build-version`, which reaches the artifact names, the update feed, and the upload validation as one value. The manifests keep the product version, so a test build no longer rewrites the release family and leaves nothing to revert:
```sh
pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.20260916.1
```
`--build-version auto` proposes the next index for today, reading the published objects in the destination bucket and falling back to this target's local output directory when no bucket is configured or the listing cannot finish within its deadline. Confirm the proposal it prints before an upload; a run script forwards `--` on its own, and the packaging entry accepts it either way.
A production release publishes the product version and takes no `--build-version`. Uploading one tags the packaged commit as `desktop-v<version>` once the artifacts are public; a build from a modified checkout is not tagged, and a tagging failure prints the command to run by hand rather than failing a completed upload. Test and local builds are deliberately left untagged, and every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest so a build handed over directly remains traceable.
Version derivation does not change the fixed update channel or `nightly.yml` / `nightly-mac.yml` filenames. SemVer orders `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`, and a stable base's test version precedes that stable release. Clients only accept a greater version: replacing a feed cannot move an installed higher version to a lower corrected version. Such clients need manual installation; keep automatic downgrade disabled. The [version decision](../../.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.md) explains why the channel does not supply the prerelease identifier.
Packaging, upload, and manual macOS signature verification read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](.env.windows.example) or [macOS template](.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the target file, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. CI also creates the target file before invoking packaging.
Packaging, upload, and manual macOS signature verification read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](.env.windows.example) or [macOS template](.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the target file, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. The published version is an argument rather than a release field, and upload reads it from the completion record the packaging run wrote. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. CI also creates the target file before invoking packaging.
Every package command checks the application ID, update origin, and mode-specific signing configuration before building or downloading. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only and explicit unsigned builds do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. Run the same checks separately:
Every package command checks the application ID, update origin, and mode-specific signing configuration before building or downloading, then probes the external tools the run will use: the archive reader, and on a Windows target the installer compiler. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only and explicit unsigned builds do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. `--build-version auto` does contact the destination bucket, including under `--check`. Run the same checks separately:
```sh
pnpm --dir apps/desktop run check:package
@@ -204,7 +214,7 @@ macOS signing visits real files without following Framework symlink aliases. PAK
macOS runtime preparation caches verified single-architecture Mach-O signatures in `.desktop-build/targets/<target>/signature-cache`. Keys include input bytes and permissions, the signing probe’s actual leaf certificate, identifier, entitlement bytes, macOS version, and signing tools and policy. Git commits do not determine reuse: uncommitted byte changes invalidate the affected file. Every hit verifies cached bytes, the strict signature, certificate, identifier, entitlements, secure timestamp and hardened runtime before replacing an unchanged input. Universal binaries are signed each time. Runtime integrity and smoke checks, App signing, and Apple notarization still run. The cache requires trusted local build storage. Cache corruption or unsafe links fail the build; stop packaging before deleting the affected cache directory and retrying. Successful signing stages prune complete entries to a one-GiB payload budget; interrupted temporary entries require manual removal. Concurrent eviction can fail a reader safely. Logs report hit, miss and uncached counts.
Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted; environments and notarytool authentication arguments are not recorded. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together.
Mac package commands print `DESKTOP_PACKAGING_RECORD` with a unique directory under `apps/desktop/.desktop-build/packaging-runs/`. After reading local configuration, each run retains `run.json` (published version, product version, Git commit, dirty-worktree flag, target and Node version), `events.jsonl` (timestamped phases, durations, parallel output attribution and proxy restoration), redacted child `stdout.log` / `stderr.log`, and `result.json` (overall outcome, stage results and artifact directory). Events also record pack concurrency and whether each proxy is configured. Logs survive failures and later builds; no automatic deletion runs. Missing `result.json` means completion is unconfirmed. Known credential values are redacted; environments and notarytool authentication arguments are not recorded. Runtime preparation records package staging, installation, materialization, signing, manifests, smoke checks, descriptor verification and cleanup separately. Nested phase durations overlap and must not be added together.
App and DMG notarization each record a submission ID and separate `notarytool:upload:*` and `notarytool:wait:*` durations. Upload uses `submit --no-wait` and includes authentication, local validation, transfer and server acceptance; it is not pure transfer time. Waiting starts after that command returns and includes polling and remaining Apple processing; Apple can start processing before upload submission returns. Apple status and diagnostic logs are retained, including rejection; signature checks, acceptance checks and stapling remain owned by `@electron/notarize`. Directory-only package commands use this same measured App notarization path. Individual subprocess output remains live in the terminal, while phase failures and nested errors are retained in the journal. Both platforms record parent-process packaging failures and print redacted diagnostics, including proxy recovery instructions, to the terminal. The configuration-only `check:package` command creates no run log.
@@ -240,6 +250,10 @@ Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools
Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The optional `--signed` flag uses the Windows EV configuration below to sign test executables and the helper before embedding them; it does not enable an update feed.
The Windows uninstaller removes the Electron user-data directory (browser storage and caches under `%APPDATA%`, nested under the package scope), the `%APPDATA%` product directory, and the updater download cache under `%LOCALAPPDATA%` together with the application, then removes ordinary empty scope directories below `%APPDATA%`. The Harness home (`~/.dsh` or `DSH_HOME`: sessions, settings, credentials, plugins) is never touched, and a `DSH_HOME` published as a Windows environment variable additionally protects any target overlapping it. Silent uninstalls remove the same data; an uninstaller started with `--updated` or `/KEEP_APP_DATA`, as electron-builder does for in-place updates and for replacing an older installation from another directory, retains it. Removal runs through the native helper, which refuses protected Windows folders and paths overlapping the installation or the home, requires a fixed local drive, leaves a linked root or ancestor in place, unlinks reparse points without entering their targets, clears read-only attributes, and keeps deleting siblings after a locked file; residue never stops uninstallation and is not reported. Installation records `InstallLocation` on the Windows uninstall entry as standard inventory metadata; on Windows 11, Uninstall in the Start menu context menu opens the installed-apps list for every Win32 application, and only MSIX packages uninstall directly from there. The uninstaller declares DPI awareness and uses Microsoft YaHei UI for Chinese. This behavior is Windows-only.
Use `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --compile-only` to compile isolated English and Chinese fixtures with a per-run scoped package name. Omit `--compile-only` to run the native remover regression and the interactive, silent, `--updated`, `/KEEP_APP_DATA` and `DSH_HOME`-inside-Electron-data checks against seeded data. Compilation does not establish installed-uninstall behavior.
### Windows EV signing
Runtime signing shares complete signed files across the current Windows account's worktrees. `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` in `.env.windows` selects an absolute fixed-drive directory; the default is `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`. Cache directories must belong to the current account and exclude other ordinary accounts from their access permissions; linked paths are rejected. Entries identify the original bytes, public certificate and signing toolchain. Every restoration checks the digest, Windows trust, timestamp and certificate before replacing the unsigned file; invalid entries stop packaging without hardware fallback. Cache age alone does not trigger signing. The cache trusts programs running as the same account and does not defend against administrators. The [runtime signature cache decision](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md) owns qualification and design limits.
+20 -6
View File
@@ -105,9 +105,9 @@ Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开
### 发布版本
每次 Desktop 打包前,第一步都要与当前用户确认完整版本号。检查所选部署环境、dsh 基础版本、保留的发布记录和已发布对象,再提出准确版本供用户确认。用户确认前,不得修改发布家族清单或启动打包;仅选择部署环境不代表用户已认可版本号。
每次 Desktop 打包前,第一步都要与当前用户确认完整版本号。检查所选部署环境、dsh 基础版本、保留的发布记录和已发布对象,再提出准确版本供用户确认。用户确认前不得启动打包;仅选择部署环境不代表用户已认可版本号。
修改任何清单前,记录当前 dsh 版本作为基础版本。production Desktop 使用完全相同的版本,包括其中的 `alpha`、`beta` 或 `rc` 标识。test 发布保留完整的预发布基础版本并追加 `.YYYYMMDD.index`;稳定基础版本则追加 `-test.YYYYMMDD.index`。
记录当前 dsh 版本作为基础版本。production Desktop 使用完全相同的版本,包括其中的 `alpha`、`beta` 或 `rc` 标识。test 发布保留完整的预发布基础版本并追加 `.YYYYMMDD.index`;稳定基础版本则追加 `-test.YYYYMMDD.index`。
| dsh 基础版本 | production Desktop | test Desktop 示例 |
|---|---|---|
@@ -116,13 +116,23 @@ Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开
| `0.1.6-rc.3` | `0.1.6-rc.3` | `0.1.6-rc.3.20260916.1` |
| `0.1.6` | `0.1.6` | `0.1.6-test.20260916.1` |
日期使用实际创建时的 Asia/Shanghai 日期。每个基础版本、每天的序号从 1 开始,检查保留的发布记录与已发布对象后递增;绝不复用已发布版本。只从记录的基础版本派生一次,不从已有测试后缀的清单继续追加。最终根包、Desktop、内置 dsh、私有 Desktop Host 和其他发布家族清单必须全部使用同一个派生版本。test 分发不发布对应的无后缀基础版本。
日期使用实际创建时的 Asia/Shanghai 日期。每个基础版本、每天的序号从 1 开始,检查保留的发布记录与已发布对象后递增;绝不复用已发布版本。test 分发不发布对应的无后缀基础版本。
把确认后的版本通过 `--build-version` 传给打包命令,该值同时决定产物文件名、更新 feed 与上传校验。清单保留产品版本,因此 test 打包不再改写发布家族,也不留下需要还原的改动:
```sh
pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.20260916.1
```
`--build-version auto` 会给出当天的下一个序号:读取目标 bucket 中已发布的对象,未配置 bucket 或列举未能在期限内完成时回退到本目标的本地输出目录。上传前请确认它打印的版本号;run script 会自行透传 `--`,打包入口两种写法都接受。
production 发布使用产品版本本身,不传 `--build-version`。其上传成功后会把打包所用 commit 打成 `desktop-v<版本>` 标签;来自有改动工作区的构建不打标签,打标签失败也只打印手工命令,不会让已完成的上传变成失败。test 与本地构建有意不留标签,而所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`,直接分发的构建同样可溯源。
版本派生不改变固定更新通道,也不改变 `nightly.yml` / `nightly-mac.yml` 文件名。SemVer 排序为 `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`,稳定基础版本的测试版低于该稳定版。客户端只接受更高版本:替换 feed 无法让已安装的较高版本更新到较低的纠正版。这类客户端需要手动安装;保持自动降级关闭。[版本决策](../../.agents/notes/implemented/process/2026-09-16-desktop-release-version-derivation.zh.md)解释为什么不能用通道名替换预发布标识。
打包、上传以及手动 macOS 签名检查使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](.env.windows.example) 或 [macOS 模板](.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从目标文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。CI 同样在运行前生成目标文件。
打包、上传以及手动 macOS 签名检查使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](.env.windows.example) 或 [macOS 模板](.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从目标文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。发布版本是命令参数而非发布字段,上传从打包写下的完成记录中读取它。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。CI 同样在运行前生成目标文件。
每条打包命令在构建与下载前检查应用 ID、更新地址和该模式需要的签名配置。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源或显式未签名打包不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。单独运行相同检查:
每条打包命令在构建与下载前检查应用 ID、更新地址和该模式需要的签名配置,随后探测本次运行要用的外部工具:归档读取工具,以及 Windows 目标的安装器编译器。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源或显式未签名打包不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。`--build-version auto` 会访问目标 bucket,`--check` 下同样如此。单独运行相同检查:
```sh
pnpm --dir apps/desktop run check:package
@@ -204,7 +214,7 @@ macOS 签名遍历真实文件,不跟随 Framework 的软链接别名。PAK
macOS 运行时准备将已验证的单架构 Mach-O 签名缓存在 `.desktop-build/targets/<target>/signature-cache`。缓存键包含输入字节与权限、签名探针实际使用的叶证书、签名标识、entitlement 字节、macOS 版本,以及签名工具与策略。复用不取决于 Git 提交:未提交的字节变更会使对应文件失效。每次命中都验证缓存字节、严格签名、证书、标识、entitlements、安全时间戳和 hardened runtime,再替换未被并发修改的输入。通用二进制每次重新签名。运行时完整性与 smoke 检查、App 签名和 Apple 公证仍会执行。缓存要求受信任的本地构建存储。缓存损坏或不安全链接会令构建失败;停止打包后删除对应缓存目录再重试。成功的签名阶段会将完整缓存条目的内容总量裁剪至一 GiB;中断留下的临时条目需手动清理。并发淘汰可能使读取方安全失败。日志记录命中、未命中及未缓存数量。
Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖;不记录环境变量全集或 notarytool 认证参数。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。
Mac 打包命令通过 `DESKTOP_PACKAGING_RECORD` 输出 `apps/desktop/.desktop-build/packaging-runs/` 下的唯一目录。读取本地配置后,每次运行保留 `run.json`(发布版本、产品版本、Git 提交、工作区是否有改动、目标及 Node 版本)、`events.jsonl`(带时间戳的阶段、耗时、并行输出归属及代理恢复状态)、脱敏后的子进程 `stdout.log` / `stderr.log`,以及 `result.json`(整体结果、阶段结果和产物目录)。事件还记录打包并发数及各代理是否配置。失败和后续打包不会清除日志;没有自动删除流程。缺少 `result.json` 表示完成状态未经确认。已知凭据值会被遮盖;不记录环境变量全集或 notarytool 认证参数。运行时准备分别记录包暂存、安装、依赖树复制、签名、清单生成、smoke 检查、描述文件校验和清理。嵌套阶段的耗时存在重叠,不能直接相加。
App 和 DMG 公证分别记录 submission ID,以及独立的 `notarytool:upload:*` 和 `notarytool:wait:*` 耗时。上传使用 `submit --no-wait`,包含认证、本地校验、传输和服务端受理,并非纯传输时间。等待从该命令返回后开始,包含轮询及剩余的 Apple 处理时间;Apple 可能在上传提交命令返回前已开始处理。日志保留 Apple 状态与诊断信息,包括拒绝结果;签名检查、接受状态校验和 stapling 仍由 `@electron/notarize` 负责。仅生成目录的打包命令也使用同一条可计时的 App 公证路径。各子进程输出仍实时显示在终端,阶段失败及嵌套错误保留在事件日志中。两个平台均记录父进程打包失败,并在终端显示脱敏诊断,包括代理恢复操作指引。仅检查配置的 `check:package` 命令不创建运行日志。
@@ -240,6 +250,10 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI
在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。可选的 `--signed` 标志使用下文的 Windows EV 配置,在嵌入前对测试程序和辅助库签名;它不会启用更新源。
Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APPDATA%` 下按包作用域嵌套的浏览器存储与缓存)、`%APPDATA%` 下的产品目录,以及 `%LOCALAPPDATA%` 下的更新下载缓存,随后移除 `%APPDATA%` 之下普通且已空的作用域目录。Harness 主目录(`~/.dsh` 或 `DSH_HOME`:会话、设置、凭据、插件)不会被触碰;以 Windows 环境变量发布的 `DSH_HOME` 还会保护所有与其重叠的目标。静默卸载删除相同的数据;以 `--updated` 或 `/KEEP_APP_DATA` 启动的卸载程序保留数据,electron-builder 在原地更新和从其他目录替换旧安装时正是这样启动它。删除通过原生辅助程序执行:它拒绝受保护的 Windows 目录以及与安装目录或主目录重叠的路径,要求固定的本地驱动器,链接的根目录或祖先目录原样保留,遇到重解析点只解除链接而不进入目标,清除只读属性,并在遇到被占用文件后继续删除其余兄弟项;残余不会中止卸载,也不会提示。安装时在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据;在 Windows 11 上,开始菜单右键菜单中的“卸载”对所有 Win32 应用都会打开已安装应用列表,只有 MSIX 包能从那里直接卸载。卸载程序声明 DPI 感知,中文使用微软雅黑 UI。此行为仅适用于 Windows。
使用 `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --compile-only` 以每次运行唯一的带作用域包名编译独立的中英文夹具。省略 `--compile-only` 可对预置数据运行原生删除器回归,以及交互、静默、`--updated`、`/KEEP_APP_DATA` 和 `DSH_HOME` 位于 Electron 数据内的检查。编译本身不能证明已安装卸载行为。
### Windows EV 签名
运行时签名在当前 Windows 账户的各 worktree 间共享完整的已签名文件。`.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` 指定固定本地磁盘上的绝对目录;默认值为 `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`。缓存目录必须属于当前账户,访问权限不得向其他普通账户开放;带链接的路径会被拒绝。缓存项标识原始字节、公钥证书和签名工具链。每次恢复都检查摘要、Windows 信任状态、时间戳和证书,再替换未签名文件;缓存项无效会停止打包,不回退到硬件签名。不会仅因缓存较旧而重新签名。缓存信任同账户运行的程序,不防御管理员。[运行时签名缓存决策](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md)定义验收要求和设计限制。
@@ -61,6 +61,7 @@ export interface DesktopElectronBuilderConfig {
* @param hostPlatform - Build-host platform used when no explicit target is present.
* @param hostArch - Build-host architecture used when no explicit target is present.
* @param preparedRuntime - Verified private qualification runtime; ordinary releases use target-owned resources.
* @param preparedRuntimeVersion - Version that private runtime declares, which qualification rewrites away from the product version.
* @returns electron-builder configuration.
*/
export function createElectronBuilderConfig(
@@ -68,6 +69,7 @@ export function createElectronBuilderConfig(
hostPlatform?: NodeJS.Platform,
hostArch?: string,
preparedRuntime?: string,
preparedRuntimeVersion?: string,
): DesktopElectronBuilderConfig
declare const electronBuilderConfig: DesktopElectronBuilderConfig
+167
View File
@@ -0,0 +1,167 @@
// Windows data cleanup: protect shell roots and pin directory names while enumerating.
#pragma once
#include <shlobj.h>
#include <string>
#include <vector>
namespace uninstall_data {
struct Handles {
std::vector<HANDLE> values;
Handles() = default;
Handles(const Handles&) = delete;
Handles& operator=(const Handles&) = delete;
~Handles() { for (HANDLE value : values) CloseHandle(value); }
};
inline bool Contains(const std::wstring& parent, const std::wstring& child) {
return _wcsicmp(parent.c_str(), child.c_str()) == 0
|| (child.size() > parent.size() && child[parent.size()] == L'\\'
&& _wcsnicmp(parent.c_str(), child.c_str(), parent.size()) == 0);
}
inline bool Normalize(LPCWSTR input, std::wstring& result) {
if (!input || wcslen(input) < 4 || input[1] != L':' || input[2] != L'\\') return false;
if (wcspbrk(input + 3, L"/:*?\"<>|\r\n\t")) return false;
WCHAR buffer[32768];
DWORD length = GetFullPathNameW(input, ARRAYSIZE(buffer), buffer, nullptr);
if (!length || length >= ARRAYSIZE(buffer)) return false;
result.assign(buffer, length);
length = GetLongPathNameW(result.c_str(), buffer, ARRAYSIZE(buffer));
if (length > 0 && length < ARRAYSIZE(buffer)) result.assign(buffer, length);
while (result.size() > 3 && result.back() == L'\\') result.pop_back();
if (result.size() <= 3 || GetDriveTypeW(result.substr(0, 3).c_str()) != DRIVE_FIXED) return false;
for (size_t start = 3; start < result.size();) {
size_t end = result.find(L'\\', start);
if (end == std::wstring::npos) end = result.size();
if (end == start || result[end - 1] == L'.' || result[end - 1] == L' ') return false;
start = end + 1;
}
return true;
}
// Listing access takes part in share checks, so the open handle refuses the DELETE access a rename or removal needs.
inline DWORD Pin(const std::wstring& path, Handles& handles) {
HANDLE handle = CreateFileW((L"\\\\?\\" + path).c_str(), FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES,
FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr);
if (handle == INVALID_HANDLE_VALUE) return GetLastError();
handles.values.push_back(handle);
BY_HANDLE_FILE_INFORMATION info{};
if (!GetFileInformationByHandle(handle, &info)) return GetLastError();
if (!(info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
|| (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) return ERROR_ACCESS_DENIED;
return ERROR_SUCCESS;
}
inline DWORD Unlink(const std::wstring& extended, DWORD attributes) {
if (attributes & FILE_ATTRIBUTE_READONLY) SetFileAttributesW(extended.c_str(), FILE_ATTRIBUTE_NORMAL);
const BOOL removed = (attributes & FILE_ATTRIBUTE_DIRECTORY)
? RemoveDirectoryW(extended.c_str()) : DeleteFileW(extended.c_str());
return removed ? ERROR_SUCCESS : GetLastError();
}
// Every sibling is attempted; the first error is returned after the whole tree has been visited.
inline DWORD RemoveTree(const std::wstring& path) {
const std::wstring extended = L"\\\\?\\" + path;
DWORD attributes = GetFileAttributesW(extended.c_str());
if (attributes == INVALID_FILE_ATTRIBUTES) {
DWORD error = GetLastError();
return error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND ? ERROR_SUCCESS : error;
}
DWORD first = ERROR_SUCCESS;
if ((attributes & FILE_ATTRIBUTE_DIRECTORY) && !(attributes & FILE_ATTRIBUTE_REPARSE_POINT)) {
Handles pinned;
DWORD error = Pin(path, pinned);
if (error) return error;
WIN32_FIND_DATAW entry{};
HANDLE search = FindFirstFileW((extended + L"\\*").c_str(), &entry);
if (search == INVALID_HANDLE_VALUE) {
error = GetLastError();
if (error != ERROR_FILE_NOT_FOUND) return error;
} else {
do {
if (wcscmp(entry.cFileName, L".") == 0 || wcscmp(entry.cFileName, L"..") == 0) continue;
error = RemoveTree(path + L"\\" + entry.cFileName);
if (error && !first) first = error;
} while (FindNextFileW(search, &entry));
error = GetLastError();
FindClose(search);
if (error != ERROR_NO_MORE_FILES && !first) first = error;
}
}
DWORD error = Unlink(extended, attributes);
return first ? first : error;
}
// Never select a shell folder, its ancestor, Windows/Program Files descendants,
// a path overlapping the installation, or a path overlapping the protected root
// (the configured Harness home). Linked descendants are unlinked only.
inline DWORD Remove(LPCWSTR input, LPCWSTR installation, LPCWSTR protectedRoot) {
std::wstring path, install;
if (!Normalize(input, path) || !Normalize(installation, install)) return ERROR_INVALID_NAME;
if (Contains(path, install) || Contains(install, path)) return ERROR_ACCESS_DENIED;
if (protectedRoot && *protectedRoot) {
std::wstring home;
// An unusable protected root cannot establish separation from retained data.
if (!Normalize(protectedRoot, home)) return ERROR_INVALID_NAME;
if (Contains(path, home) || Contains(home, path)) return ERROR_ACCESS_DENIED;
}
struct ComScope {
HRESULT result = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
~ComScope() { if (SUCCEEDED(result)) CoUninitialize(); }
} com;
if (FAILED(com.result) && com.result != RPC_E_CHANGED_MODE) return ERROR_ACCESS_DENIED;
// This helper is x86; the x64 Program Files known-folder id is unavailable to it.
WCHAR nativePrograms[32768];
DWORD bytes = sizeof(nativePrograms);
if (RegGetValueW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion", L"ProgramFilesDir",
RRF_RT_REG_SZ | RRF_SUBKEY_WOW6464KEY, nullptr, nativePrograms, &bytes) != ERROR_SUCCESS) return ERROR_ACCESS_DENIED;
std::wstring nativeFolder;
if (!Normalize(nativePrograms, nativeFolder) || Contains(path, nativeFolder) || Contains(nativeFolder, path)) return ERROR_ACCESS_DENIED;
const KNOWNFOLDERID* protectedFolders[] = {&FOLDERID_Profile, &FOLDERID_RoamingAppData,
&FOLDERID_LocalAppData, &FOLDERID_Desktop, &FOLDERID_Documents, &FOLDERID_Downloads,
&FOLDERID_Pictures, &FOLDERID_Music, &FOLDERID_Videos, &FOLDERID_Public, &FOLDERID_ProgramData,
&FOLDERID_Windows, &FOLDERID_ProgramFiles, &FOLDERID_ProgramFilesX86};
for (const KNOWNFOLDERID* id : protectedFolders) {
PWSTR value = nullptr;
HRESULT result = SHGetKnownFolderPath(*id, KF_FLAG_DONT_VERIFY, nullptr, &value);
if (FAILED(result)) return ERROR_ACCESS_DENIED;
// Known-folder paths are already full paths; a redirected folder on another volume must stay protected.
const std::wstring folder(value);
CoTaskMemFree(value);
if (Contains(path, folder)) return ERROR_ACCESS_DENIED;
if ((id == &FOLDERID_Windows || id == &FOLDERID_ProgramFiles || id == &FOLDERID_ProgramFilesX86)
&& Contains(folder, path)) return ERROR_ACCESS_DENIED;
}
Handles parents;
for (size_t end = path.find(L'\\', 3); end != std::wstring::npos; end = path.find(L'\\', end + 1)) {
DWORD error = Pin(path.substr(0, end), parents);
if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) return ERROR_SUCCESS;
if (error) return error;
}
// A selected root must be a real directory, even though links inside it may be removed.
{
Handles root;
DWORD error = Pin(path, root);
if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) return ERROR_SUCCESS;
if (error) return error;
}
return RemoveTree(path);
}
// Remove the ordinary, empty directories between a removed path and its stop ancestor.
// A linked or populated parent ends the walk; the stop directory itself is never removed.
inline DWORD RemoveEmptyParents(LPCWSTR input, LPCWSTR stopInput) {
std::wstring path, stop;
if (!Normalize(input, path) || !Normalize(stopInput, stop)) return ERROR_INVALID_NAME;
if (!Contains(stop, path) || _wcsicmp(stop.c_str(), path.c_str()) == 0) return ERROR_ACCESS_DENIED;
for (size_t end = path.rfind(L'\\'); end != std::wstring::npos && end > stop.size(); end = path.rfind(L'\\', end - 1)) {
const std::wstring parent = L"\\\\?\\" + path.substr(0, end);
const DWORD attributes = GetFileAttributesW(parent.c_str());
if (attributes == INVALID_FILE_ATTRIBUTES) return GetLastError();
if (!(attributes & FILE_ATTRIBUTE_DIRECTORY) || (attributes & FILE_ATTRIBUTE_REPARSE_POINT)) return ERROR_ACCESS_DENIED;
if (!RemoveDirectoryW(parent.c_str())) return GetLastError();
}
return ERROR_SUCCESS;
}
}
+36
View File
@@ -0,0 +1,36 @@
; Electron user data and updater downloads leave with the application; the Harness home is never touched.
!include FileFunc.nsh
Var UnTarget
Var UnHome
; The helper refuses unsafe roots, the installation and the Harness home, and never descends into reparse points;
; a locked file leaves residue and never fails the uninstall. customCheckAppRunning extracts window-frame.dll first.
Function un.RemoveData
System::Call '$PLUGINSDIR\window-frame.dll::UninstallRemoveData(w "$UnTarget", w "$INSTDIR", w "$UnHome") ?c'
FunctionEnd
Function un.CleanData
${If} ${isUpdated}
Return
${EndIf}
; The installer runs an older uninstaller with /KEEP_APP_DATA when replacing it from another directory.
${GetParameters} $R0
ClearErrors
${GetOptions} $R0 "/KEEP_APP_DATA" $R1
${IfNot} ${Errors}
Return
${EndIf}
; Only a DSH_HOME published as a Windows environment variable is visible here.
ReadEnvStr $UnHome DSH_HOME
ClearErrors
StrCpy $UnTarget "$APPDATA\${PRODUCT_FILENAME}"
Call un.RemoveData
!ifdef APP_PACKAGE_NAME
; Electron derives user data from the package name; a scoped name nests it one directory deeper.
StrCpy $UnTarget "$APPDATA\${APP_PACKAGE_NAME}"
Call un.RemoveData
System::Call '$PLUGINSDIR\window-frame.dll::UninstallRemoveEmptyParents(w "$UnTarget", w "$APPDATA") ?c'
!endif
StrCpy $UnTarget "$LOCALAPPDATA\${DSH_UPDATER_CACHE_NAME}"
Call un.RemoveData
FunctionEnd
+11
View File
@@ -12,9 +12,20 @@
#include <algorithm>
#include "progress.h"
#include "extract.h"
#include "uninstall-data.h"
using namespace Gdiplus;
// Returns a Win32 error code; no cleanup request may traverse a linked root or touch the protected root.
extern "C" __declspec(dllexport) DWORD __cdecl UninstallRemoveData(LPCWSTR path, LPCWSTR installation, LPCWSTR protectedRoot) {
return uninstall_data::Remove(path, installation, protectedRoot);
}
// Returns a Win32 error code; only ordinary empty directories strictly below the stop directory are removed.
extern "C" __declspec(dllexport) DWORD __cdecl UninstallRemoveEmptyParents(LPCWSTR path, LPCWSTR stop) {
return uninstall_data::RemoveEmptyParents(path, stop);
}
// Match the affected executable, not another user's or directory's same-named application.
// Returns 0 while running, 1 when absent, and -1 if the process list cannot be read.
extern "C" __declspec(dllexport) int __cdecl InstallerFindProcess(LPCWSTR executable) {
@@ -0,0 +1,32 @@
/** Commit a build came from, and whether its checkout carried uncommitted changes. */
export interface DesktopBuildCommit {
readonly commit: string
readonly dirty: boolean
}
/** Environment variable that carries the packaged commit. */
export const DESKTOP_BUILD_COMMIT_ENV: 'DSH_DESKTOP_BUILD_COMMIT'
/** Environment variable that records whether the packaged checkout had uncommitted changes. */
export const DESKTOP_BUILD_DIRTY_ENV: 'DSH_DESKTOP_BUILD_DIRTY'
/**
* Read the checkout's current commit and whether it carries uncommitted changes.
* @param repositoryRoot - Directory to inspect.
* @returns The commit being packaged and whether its tree was modified.
*/
export function readDesktopBuildCommit(repositoryRoot: string): DesktopBuildCommit
/**
* Resolve the commit a parent packaging process recorded.
* @param env - Packaging environment.
* @returns The packaged commit, or undefined outside a packaging run.
*/
export function resolveDesktopBuildCommit(env: NodeJS.ProcessEnv): DesktopBuildCommit | undefined
/**
* Describe the packaged commit as the environment variables child processes read.
* @param packaged - Commit and tree state to pass down.
* @returns Variables to merge into a child environment.
*/
export function desktopBuildCommitEnvironment(packaged: DesktopBuildCommit): Record<string, string>
@@ -0,0 +1,57 @@
/**
* Carry the commit a build came from into its artifacts.
*
* A build that reaches a colleague or a test feed is not reachable from a tag,
* so the only way back to its sources is what the build recorded about itself.
* The packaging entry reads the checkout once and passes the result to every
* child process, which run against a build tree rather than a checkout.
*/
import { execFileSync } from 'node:child_process'
/** Environment variable that carries the packaged commit. */
export const DESKTOP_BUILD_COMMIT_ENV = 'DSH_DESKTOP_BUILD_COMMIT'
/** Environment variable that records whether the packaged checkout had uncommitted changes. */
export const DESKTOP_BUILD_DIRTY_ENV = 'DSH_DESKTOP_BUILD_DIRTY'
/**
* Read the checkout's current commit and whether it carries uncommitted changes.
* @param {string} repositoryRoot - Directory to inspect.
* @returns {{ commit: string, dirty: boolean }} The commit being packaged and whether its tree was modified.
*/
export function readDesktopBuildCommit(repositoryRoot) {
const git = (args) => execFileSync('git', args, { cwd: repositoryRoot, encoding: 'utf8' }).trim()
return {
commit: git(['rev-parse', 'HEAD']),
dirty: git(['status', '--porcelain', '--untracked-files=normal']) !== '',
}
}
/**
* Resolve the commit a parent packaging process recorded.
* @param {NodeJS.ProcessEnv} env - Packaging environment.
* @returns {{ commit: string, dirty: boolean } | undefined} The packaged commit, or undefined outside a packaging run.
*/
export function resolveDesktopBuildCommit(env) {
const commit = env[DESKTOP_BUILD_COMMIT_ENV]?.trim()
if (commit === undefined || commit === '') return undefined
if (!/^[0-9a-f]{40}$/u.test(commit)) throw new Error(`desktop build commit: ${DESKTOP_BUILD_COMMIT_ENV} must be a commit hash`)
const dirty = env[DESKTOP_BUILD_DIRTY_ENV]?.trim()
if (dirty !== undefined && dirty !== '' && dirty !== '0' && dirty !== '1') {
throw new Error(`desktop build commit: ${DESKTOP_BUILD_DIRTY_ENV} must be 0 or 1`)
}
return { commit, dirty: dirty === '1' }
}
/**
* Describe the packaged commit as the environment variables child processes read.
* @param {{ commit: string, dirty: boolean }} packaged - Commit and tree state to pass down.
* @returns {Record<string, string>} Variables to merge into a child environment.
*/
export function desktopBuildCommitEnvironment(packaged) {
return {
[DESKTOP_BUILD_COMMIT_ENV]: packaged.commit,
[DESKTOP_BUILD_DIRTY_ENV]: packaged.dirty ? '1' : '0',
}
}
@@ -0,0 +1,156 @@
/**
* Suggest the next build version for a product version, so the sequence number
* is derived rather than remembered.
*
* `apps/desktop/README.md` gives the form as `<product version>.<date>.<index>`
* for a prerelease base and `<product version>-test.<date>.<index>` for a
* stable one. What makes an index correct is which ones are taken, and that is
* recorded where the builds went: the update bucket for an uploaded build, the
* local output directory for one that was only handed over. Asking the bucket
* first is what keeps a test feed's numbering unique across machines, and a
* fresh CI checkout has no local artifacts to fall back on.
*
* Reusing an index would overwrite a published installer and invalidate the
* checksum in the feed that already points at it, so a bucket that answers
* must answer completely: the listing follows its pages, and a query that
* cannot finish within the deadline falls back to the local scan rather than
* numbering against a partial answer.
*/
import { readdir } from 'node:fs/promises'
import { parse } from 'semver'
import { desktopBuildVersionPrefix, validateDesktopBuildVersion } from './desktop-build-version.mjs'
import { DESKTOP_AUTO_UPDATE_ENV, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs'
import { createDesktopCos, DESKTOP_COS_REGION } from './desktop-cos.ts'
import type { DesktopPackageTargetName } from './package-target.ts'
/** How long the whole bucket listing may take before the suggestion falls back to local artifacts. */
const LISTING_DEADLINE_MS = 8_000
/** Objects one listing page may return. */
const LISTING_PAGE_SIZE = 1000
/** Artifact name electron-builder writes for one build, on either platform. */
const ARTIFACT = /(?:^|\/)deepseek-harness-(?<version>.+)-(?:mac|win)-(?:arm64|x64)\.(?:exe|dmg|zip)$/u
/** Inputs that decide which versions are already taken. */
export interface DesktopBuildVersionSuggestionOptions {
readonly productVersion: string
readonly target: DesktopPackageTargetName
readonly environment: NodeJS.ProcessEnv
/** Date segment to number within; defaults to today where the build runs. */
readonly date?: string
/** Directory electron-builder writes installers into for this build. */
readonly artifactsRoot: string
}
/**
* Format a date as the convention's segment.
* @param date - Date to format.
* @returns The date as `YYYYMMDD` in the build host's own time zone.
*/
export function desktopBuildDateSegment(date: Date = new Date()): string {
const month = String(date.getMonth() + 1).padStart(2, '0')
return `${String(date.getFullYear())}${month}${String(date.getDate()).padStart(2, '0')}`
}
/**
* Read the sequence numbers already used for one product version and date.
* @param versions - Versions found in a bucket or directory.
* @param prefix - Everything a numbered build carries before its index.
* @returns Every index present, unordered.
*/
function sequenceNumbers(versions: Iterable<string>, prefix: string): number[] {
const numbers: number[] = []
for (const version of versions) {
if (!version.startsWith(prefix)) continue
const index = version.slice(prefix.length)
if (/^\d+$/u.test(index)) numbers.push(Number(index))
}
return numbers
}
/**
* Read the versions one output directory already holds.
* @param artifactsRoot - Directory electron-builder wrote installers into.
* @returns Versions parsed from artifact names.
*/
async function localVersions(artifactsRoot: string): Promise<string[]> {
const entries = await readdir(artifactsRoot).catch(() => [])
return entries.map(entry => ARTIFACT.exec(entry)?.groups?.version)
.filter((version): version is string => version !== undefined && parse(version) !== null)
}
/**
* Read every version one update bucket publishes for a target.
* @param options - Product version, target, and environment naming the bucket.
* @returns Versions parsed from object names, or undefined when the bucket cannot be listed completely in time.
*/
async function remoteVersions(options: DesktopBuildVersionSuggestionOptions): Promise<string[] | undefined> {
const platform = options.target === 'win-x64' ? 'win32' as const : 'darwin' as const
const arch = options.target === 'mac-arm64' ? 'arm64' : 'x64'
// An unconfigured destination has nothing to be unique against; an invalid one must not be mistaken for it.
if (options.environment[DESKTOP_AUTO_UPDATE_ENV] === undefined
&& options.environment.DOWNLOAD_TEST_ORIGIN === undefined) return undefined
const update = resolveDesktopUploadConfig(options.environment, platform, arch)
const secretId = options.environment[update.secretIdEnvName]?.trim()
const secretKey = options.environment[update.secretKeyEnvName]?.trim()
if (secretId === undefined || secretId === '' || secretKey === undefined || secretKey === '') return undefined
// The SDK has no per-request abort, so its own timeout is what releases an unanswered socket.
const cos = createDesktopCos({ secretId, secretKey }, LISTING_DEADLINE_MS)
const deadline = Date.now() + LISTING_DEADLINE_MS
const versions: string[] = []
let marker: string | undefined
try {
do {
const page = await Promise.race([
new Promise<{ keys: string[]; next: string | undefined }>((resolveListing, rejectListing) => {
cos.getBucket({
Bucket: update.bucket, Region: DESKTOP_COS_REGION, MaxKeys: LISTING_PAGE_SIZE,
Prefix: `${update.binaryKeyPrefix}/deepseek-harness-`, ...marker === undefined ? {} : { Marker: marker },
}, (error, data) => {
if (error !== null && error !== undefined) rejectListing(error instanceof Error ? error : new Error(String(error)))
else {
resolveListing({
keys: (data?.Contents ?? []).map(object => object.Key),
next: data?.IsTruncated === 'true' ? data.NextMarker ?? data.Contents?.at(-1)?.Key : undefined,
})
}
})
}),
new Promise<never>((_resolveDeadline, rejectDeadline) => {
setTimeout(() => rejectDeadline(new Error('listing deadline')), Math.max(0, deadline - Date.now())).unref()
}),
])
for (const key of page.keys) {
const version = ARTIFACT.exec(key)?.groups?.version
if (version !== undefined && parse(version) !== null) versions.push(version)
}
marker = page.next
} while (marker !== undefined)
}
catch (unfinished) {
// Numbering against a partial listing could reuse a published index, so an unfinished query yields nothing.
process.stdout.write(`desktop package: the update bucket did not answer completely (${
unfinished instanceof Error ? unfinished.message : String(unfinished)}); numbering from local artifacts\n`)
return undefined
}
return versions
}
/**
* Suggest the next build version for today, numbering after what is already taken.
* @param options - Product version, target, and environment to search.
* @returns A validated build version whose index is free.
*/
export async function suggestDesktopBuildVersion(options: DesktopBuildVersionSuggestionOptions): Promise<string> {
const prefix = `${desktopBuildVersionPrefix(options.productVersion)}${options.date ?? desktopBuildDateSegment()}.`
const published = await remoteVersions(options)
const taken = published ?? await localVersions(options.artifactsRoot)
const used = sequenceNumbers(taken, prefix)
const next = used.length === 0 ? 1 : Math.max(...used) + 1
const suggestion = validateDesktopBuildVersion(`${prefix}${String(next)}`, options.productVersion)
process.stdout.write(`desktop package: numbering ${suggestion} after ${String(used.length)} ${
published === undefined ? 'local artifact' : 'published build'}${used.length === 1 ? '' : 's'} for ${prefix}*\n`)
return suggestion
}
@@ -0,0 +1,25 @@
/** Environment variable that carries the build version through one packaging and upload run. */
export const DESKTOP_BUILD_VERSION_ENV: 'DSH_DESKTOP_BUILD_VERSION'
/**
* Validate a build version against the product version it extends.
* @param buildVersion - Version this build publishes.
* @param productVersion - Version the manifests declare.
* @returns The version as semver normalizes it, which is what the artifacts will carry.
*/
export function validateDesktopBuildVersion(buildVersion: string, productVersion: string): string
/**
* Resolve the version a build publishes.
* @param env - Packaging or upload environment.
* @param productVersion - Version the manifests declare.
* @returns The build version when one is present, otherwise the product version.
*/
export function resolveDesktopBuildVersion(env: NodeJS.ProcessEnv, productVersion: string): string
/**
* Everything a build version carries before its date, including the trailing separator.
* @param productVersion - Version the manifests declare.
* @returns The prefix shared by every build version of that product version.
*/
export function desktopBuildVersionPrefix(productVersion: string): string
@@ -0,0 +1,100 @@
/**
* Resolve the version one build publishes, which is not always the version the
* repository declares.
*
* A production release publishes the version in the manifests, aligned with the
* `dsh` npm package. A test build publishes a version that appends a date and a
* sequence number, so one test feed can carry several builds of a single
* product version: `0.1.6-alpha.1.20260916.1` from a prerelease base and
* `0.1.6-test.20260916.1` from a stable one, as the release versions table in
* `apps/desktop/README.md` gives them. Passing that version here keeps it out
* of the manifests, so the tracked version stays the product's while the build
* version reaches electron-builder, the update feed, and the upload validation
* as one input.
*
* `electron-updater` compares feed versions with `semver.gt` against the
* installed `app.getVersion()`, so validation uses the same library. Sequence
* numbers order builds within a feed under either form. The forms differ only
* against the base itself: builds extending a prerelease outrank it, while
* `0.1.6-test.1` ranks below `0.1.6`, which is why the stable form belongs to a
* test feed that never serves the production release.
*/
import { parse } from 'semver'
/** Environment variable that carries the build version through one packaging and upload run. */
export const DESKTOP_BUILD_VERSION_ENV = 'DSH_DESKTOP_BUILD_VERSION'
/** Prerelease field that opens a test build's suffix on a stable product version. */
const STABLE_TEST_FIELD = 'test'
/**
* Parse a version the updater would accept.
* @param {string} version - Version to read.
* @param {string} label - Description used in failures.
* @returns {import('semver').SemVer} The parsed version.
*/
function parseVersion(version, label) {
const parsed = parse(version, { loose: false })
if (parsed === null) throw new Error(`desktop build version: ${label} ${version} is not a version`)
if (parsed.build.length > 0) {
// Build metadata does not participate in precedence, so two builds would compare equal to the updater.
throw new Error(`desktop build version: ${label} ${version} cannot carry build metadata`)
}
return parsed
}
/**
* The prerelease fields every build version for one product version starts with.
* @param {import('semver').SemVer} product - Parsed product version.
* @returns {readonly (string | number)[]} Fields a build version must repeat before its date.
*/
function requiredFields(product) {
return product.prerelease.length === 0 ? [STABLE_TEST_FIELD] : product.prerelease
}
/**
* Validate a build version against the product version it extends.
* @param {string} buildVersion - Version this build publishes.
* @param {string} productVersion - Version the manifests declare.
* @returns {string} The version as semver normalizes it, which is what the artifacts will carry.
*/
export function validateDesktopBuildVersion(buildVersion, productVersion) {
const build = parseVersion(buildVersion, 'build version')
const product = parseVersion(productVersion, 'product version')
if (build.version === product.version) return build.version
if (build.compareMain(product) !== 0) {
throw new Error(`desktop build version: ${buildVersion} must extend product version ${productVersion}`)
}
const required = requiredFields(product)
const extendsProduct = build.prerelease.length > required.length
&& required.every((field, index) => build.prerelease[index] === field)
if (!extendsProduct) {
throw new Error(`desktop build version: ${buildVersion} must extend ${productVersion} as ${
desktopBuildVersionPrefix(productVersion)}<date>.<sequence>`)
}
return build.version
}
/**
* Resolve the version a build publishes.
* @param {NodeJS.ProcessEnv} env - Packaging or upload environment.
* @param {string} productVersion - Version the manifests declare.
* @returns {string} The build version when one is present, otherwise the product version.
*/
export function resolveDesktopBuildVersion(env, productVersion) {
const buildVersion = env[DESKTOP_BUILD_VERSION_ENV]?.trim()
if (buildVersion === undefined || buildVersion === '') return productVersion
return validateDesktopBuildVersion(buildVersion, productVersion)
}
/**
* Everything a build version carries before its date, including the trailing separator.
* @param {string} productVersion - Version the manifests declare.
* @returns {string} The prefix shared by every build version of that product version.
*/
export function desktopBuildVersionPrefix(productVersion) {
const product = parseVersion(productVersion, 'product version')
const [release] = product.version.split('-')
return `${release}-${requiredFields(product).join('.')}.`
}
+3 -2
View File
@@ -29,9 +29,10 @@ interface CosRequestOptions {
* caller names none; that header is removed here so uploading leaves cache policy to deployment
* infrastructure.
* @param credentials SecretId and SecretKey for the selected deployment.
* @param timeoutMs Request-level inactivity deadline; a caller that must not wait out a transfer passes its own.
* @returns A COS client that sends HTTPS requests to the region named by each call.
*/
export function createDesktopCos(credentials: DesktopCosCredentials): COS {
export function createDesktopCos(credentials: DesktopCosCredentials, timeoutMs: number = TRANSFER_TIMEOUT_MS): COS {
const cos = new COS({
SecretId: credentials.secretId,
SecretKey: credentials.secretKey,
@@ -41,7 +42,7 @@ export function createDesktopCos(credentials: DesktopCosCredentials): COS {
AutoSwitchHost: false,
CorrectClockSkew: false,
ChunkRetryTimes: 0,
Timeout: TRANSFER_TIMEOUT_MS,
Timeout: timeoutMs,
UploadCheckContentMd5: false,
})
cos.on('before-send', (options: CosRequestOptions) => {
@@ -0,0 +1,80 @@
/**
* Record a production release in git after it reaches the download domain.
*
* A build that only goes to a colleague or a test feed carries its commit
* inside the artifact, which is enough to trace it. A build that external users
* install should also be reachable from the repository, so a production upload
* tags the commit it was packaged from. The tag is written after the upload
* succeeds: a tag for a release that never shipped would be worse than none.
*
* Tagging never fails an upload that already completed. The artifacts are
* public by then, and nothing about a missing tag can be repaired by reporting
* the upload as failed; the operator is told what to run instead.
*/
import { execFileSync } from 'node:child_process'
/** What tagging one uploaded release did, or why it did nothing. */
export interface DesktopReleaseTagResult {
readonly tag: string
readonly status: 'created' | 'present' | 'failed'
/** Commands that finish the tagging a failure interrupted, in order. */
readonly recovery?: readonly string[]
readonly detail?: string
}
/**
* Name the tag one published version carries.
* @param version - Version the release published.
* @returns The tag name, distinct from the `dsh-v*` tags the npm family publishes from.
*/
export function desktopReleaseTag(version: string): string {
return `desktop-v${version}`
}
/**
* Tag and push the commit a production release was packaged from.
* @param options - Published version, packaged commit, repository, and remote.
* @returns What tagging did; a failure is reported rather than thrown.
*/
export function tagDesktopRelease(options: {
readonly version: string
readonly commit: string
readonly repositoryRoot: string
readonly remote?: string
readonly run?: (command: string, args: readonly string[]) => string
}): DesktopReleaseTagResult {
const tag = desktopReleaseTag(options.version)
const remote = options.remote ?? 'origin'
const run = options.run
?? ((command: string, args: readonly string[]) =>
execFileSync(command, [...args], { cwd: options.repositoryRoot, encoding: 'utf8' }).trim())
const create = `git tag ${tag} ${options.commit}`
const push = `git push ${remote} ${tag}`
// A local tag that already exists makes `git tag` fail, so recovery must name only the steps still outstanding.
let tagged = false
try {
const existing = run('git', ['tag', '--list', tag])
if (existing !== '') {
const named = run('git', ['rev-list', '-n', '1', tag])
if (named !== options.commit) {
return { tag, status: 'failed', detail: `${tag} already names ${named}, not the uploaded commit ${options.commit}` }
}
tagged = true
run('git', ['push', remote, tag])
return { tag, status: 'present' }
}
run('git', ['tag', tag, options.commit])
tagged = true
run('git', ['push', remote, tag])
return { tag, status: 'created' }
}
catch (error) {
return {
tag,
status: 'failed',
recovery: tagged ? [push] : [create, push],
detail: error instanceof Error ? error.message : String(error),
}
}
}
@@ -0,0 +1,102 @@
/**
* Probe the external tools a packaging run will shell out to, before it starts.
*
* The packaging sequence reaches its first archive step minutes in, and its
* first installer step later still, so a tool that is absent or behaves
* differently than the scripts assume wastes a whole run. Each probe here
* exercises the specific behaviour the run depends on rather than merely
* checking that the executable resolves.
*/
import { execFile } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { promisify } from 'node:util'
const run = promisify(execFile)
/** One tool the packaging run requires, and what went wrong with it. */
export interface DesktopToolchainProbeFailure {
readonly tool: string
readonly detail: string
}
async function probeTar(): Promise<string | undefined> {
// GNU tar reads the colon in an absolute Windows path as a remote-host separator, which the
// release scripts avoid by running beside the archive; this probe confirms the archive reader works there.
const directory = await mkdtemp(join(tmpdir(), 'dsh-tar-probe-'))
try {
const member = join(directory, 'probe.txt')
await writeFile(member, 'probe\n')
const archive = join(directory, 'probe.tar.gz')
await run('tar', ['-czf', basename(archive), basename(member)], { cwd: directory, windowsHide: true, timeout: 20_000 })
const listed = await run('tar', ['-tzf', basename(archive)], { cwd: dirname(archive), windowsHide: true, timeout: 20_000 })
if (!listed.stdout.includes('probe.txt')) return `tar listed ${JSON.stringify(listed.stdout)} instead of the archived member`
return undefined
}
catch (error) {
return error instanceof Error ? error.message : String(error)
}
finally {
await rm(directory, { recursive: true, force: true })
}
}
async function probeWindowsInstallerToolchain(environment: NodeJS.ProcessEnv): Promise<DesktopToolchainProbeFailure[]> {
const failures: DesktopToolchainProbeFailure[] = []
const programFiles = environment['ProgramFiles(x86)']
const vswhere = programFiles === undefined
? undefined
: join(programFiles, 'Microsoft Visual Studio/Installer/vswhere.exe')
if (vswhere === undefined) {
failures.push({ tool: 'vswhere', detail: 'ProgramFiles(x86) is not set, so Visual Studio cannot be located' })
}
else {
try {
const located = await run(vswhere, ['-latest', '-products', '*',
'-requires', 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64', '-property', 'installationPath'],
{ windowsHide: true, timeout: 30_000 })
if (located.stdout.trim() === '') {
failures.push({ tool: 'vswhere', detail: 'Visual Studio C++ build tools are not installed, so the installer helper cannot compile' })
}
}
catch (error) {
failures.push({ tool: 'vswhere', detail: error instanceof Error ? error.message : String(error) })
}
}
return failures
}
/**
* Probe every external tool one packaging run needs.
* @param platform - Target platform; a Windows target already requires a Windows build host.
* @param environment - Packaging environment used to locate Windows tooling.
* @returns Every probe that failed, empty when the host can run the packaging sequence.
*/
export async function probeDesktopToolchain(
platform: 'darwin' | 'win32',
environment: NodeJS.ProcessEnv = process.env,
): Promise<readonly DesktopToolchainProbeFailure[]> {
const failures: DesktopToolchainProbeFailure[] = []
const tar = await probeTar()
if (tar !== undefined) failures.push({ tool: 'tar', detail: tar })
if (platform === 'win32') failures.push(...await probeWindowsInstallerToolchain(environment))
return failures
}
/**
* Probe the toolchain and fail with every problem the host has.
* @param platform - Target platform; a Windows target already requires a Windows build host.
* @param environment - Packaging environment used to locate Windows tooling.
* @returns Resolves when every probe passes.
*/
export async function requireDesktopToolchain(
platform: 'darwin' | 'win32',
environment: NodeJS.ProcessEnv = process.env,
): Promise<void> {
const failures = await probeDesktopToolchain(platform, environment)
if (failures.length === 0) return
throw new Error(`desktop package: the build host cannot run this packaging sequence:\n${
failures.map(failure => ` ${failure.tool}: ${failure.detail}`).join('\n')}`)
}
+24 -8
View File
@@ -13,6 +13,7 @@ import {
resolveDesktopUploadConfig,
} from './desktop-auto-update-environment.mjs'
import { desktopTargetBuildPaths } from './desktop-build-paths.mjs'
import { validateDesktopBuildVersion } from './desktop-build-version.mjs'
const APP_ROOT = resolve(import.meta.dirname, '..')
const REPOSITORY_ROOT = resolve(APP_ROOT, '..', '..')
@@ -47,6 +48,10 @@ export interface DesktopUploadPlan {
readonly secretIdEnvName: string
readonly secretKeyEnvName: string
readonly artifacts: readonly DesktopUploadArtifact[]
/** Commit the artifacts were packaged from, absent for a package built before builds recorded it. */
readonly commit?: string
/** Whether that checkout carried uncommitted changes. */
readonly dirty?: boolean
}
/** Filesystem and environment inputs used to validate one upload. */
@@ -191,15 +196,24 @@ export async function createDesktopUploadPlan(
join(artifactsRoot, desktopBuildRecordFilename(targetName)),
`${targetName} package completion record`,
)
// Packaging wrote the version it published; reading it back keeps release settings out of shell variables.
const recordedVersion = stringField(buildRecord.version, `${targetName} package completion record.version`)
let buildVersion: string
try {
buildVersion = validateDesktopBuildVersion(recordedVersion, dshVersion)
}
catch (error) {
throw new Error(`desktop upload: ${targetName} package completion record holds ${recordedVersion}, which is not a build of dsh ${dshVersion}: ${
error instanceof Error ? error.message : String(error)}`)
}
if (buildRecord.schemaVersion !== 1
|| buildRecord.target !== targetName
|| buildRecord.version !== dshVersion
|| buildRecord.environment !== update.environment
|| buildRecord.publicUrl !== update.publicUrl) {
throw new Error(`desktop upload: ${targetName} package completion record does not match dsh ${dshVersion} and ${update.environment} update destination`)
throw new Error(`desktop upload: ${targetName} package completion record for ${buildVersion} does not match the ${update.environment} update destination`)
}
const metadataFilename = desktopUpdateMetadataFilename(dshVersion, target.platform)
const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform)
const metadataPath = join(artifactsRoot, metadataFilename)
let metadataValue: unknown
try {
@@ -210,14 +224,14 @@ export async function createDesktopUploadPlan(
}
const metadata = object(metadataValue, metadataFilename)
const metadataVersion = stringField(metadata.version, `${metadataFilename}.version`)
if (metadataVersion !== dshVersion) {
throw new Error(`desktop upload: ${metadataFilename} version ${metadataVersion} does not match current dsh version ${dshVersion}`)
if (metadataVersion !== buildVersion) {
throw new Error(`desktop upload: ${metadataFilename} version ${metadataVersion} does not match published version ${buildVersion}`)
}
if (!Array.isArray(metadata.files) || metadata.files.length !== 1) {
throw new Error(`desktop upload: ${metadataFilename}.files must contain exactly one target update file`)
}
const base = `deepseek-harness-${dshVersion}-${target.os}-${target.arch}`
const base = `deepseek-harness-${buildVersion}-${target.os}-${target.arch}`
const updaterExtension = target.platform === 'darwin' ? 'zip' : 'exe'
const updaterInfo = updateFileInfo(metadata.files[0], `${metadataFilename}.files[0]`, `${base}.${updaterExtension}`)
const updaterPath = await verifyChecksummedArtifact(artifactsRoot, updaterInfo)
@@ -254,18 +268,20 @@ export async function createDesktopUploadPlan(
contents: dump(published),
}
artifacts.push(channelArtifact)
if (prerelease(dshVersion) === null) {
if (prerelease(buildVersion) === null) {
const stableFilename = metadataFilename.replace('nightly', 'latest')
artifacts.push({ ...channelArtifact, filename: stableFilename, key: `${update.keyPrefix}/${stableFilename}` })
}
return {
environment: update.environment,
target: targetName,
version: dshVersion,
version: buildVersion,
publicUrl: update.publicUrl,
bucket: update.bucket,
secretIdEnvName: update.secretIdEnvName,
secretKeyEnvName: update.secretKeyEnvName,
artifacts,
...typeof buildRecord.commit === 'string' ? { commit: buildRecord.commit } : {},
...typeof buildRecord.dirty === 'boolean' ? { dirty: buildRecord.dirty } : {},
}
}
@@ -1,4 +1,5 @@
import { X509Certificate } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -18,6 +19,8 @@ import {
scrubWindowsSigningEnvironment,
} from './windows-sign.mjs'
import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs'
import { resolveDesktopBuildCommit } from './desktop-build-commit.mjs'
import { resolveDesktopBuildVersion } from './desktop-build-version.mjs'
import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs'
import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs'
import { installWindowsDirectoryInstaller } from './windows-directory-installer.mjs'
@@ -36,6 +39,7 @@ import {
* @param {NodeJS.Platform} hostPlatform - Build-host platform used when no explicit target is present.
* @param {string} hostArch - Build-host architecture used when no explicit target is present.
* @param {string | undefined} preparedRuntime - Verified private dsh tree for installed-update qualification; ordinary releases use the target tree.
* @param {string | undefined} preparedRuntimeVersion - Version that private tree declares, which qualification rewrites away from the product version.
* @returns {object} electron-builder configuration.
*/
export function createElectronBuilderConfig(
@@ -43,6 +47,7 @@ export function createElectronBuilderConfig(
hostPlatform = process.platform,
hostArch = process.arch,
preparedRuntime = undefined,
preparedRuntimeVersion = undefined,
) {
const appId = resolveDesktopAppId(env)
const policy = resolveDesktopPolicyEnvironment(env)
@@ -86,9 +91,19 @@ export function createElectronBuilderConfig(
}
const update = unsigned ? undefined : resolveDesktopAutoUpdateConfig(env, resolvedPlatform, resolvedArch)
if (preparedRuntime !== undefined) buildPaths.dsh = preparedRuntime
// electron-builder merges extraMetadata into the packaged manifest, so a build version here reaches
// the artifact names, the update feed, and the installed app.getVersion() the updater compares against.
const productVersion = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version
const buildVersion = resolveDesktopBuildVersion(env, productVersion)
const packaged = resolveDesktopBuildCommit(env)
return {
appId,
extraMetadata: { dshDesktopAppId: appId, dshMandatoryUpdatePolicy: policy },
extraMetadata: {
dshDesktopAppId: appId,
dshMandatoryUpdatePolicy: policy,
...buildVersion === productVersion ? {} : { version: buildVersion },
...packaged === undefined ? {} : { dshBuildCommit: packaged.commit, dshBuildDirty: packaged.dirty },
},
productName: 'DeepSeek Harness',
artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}',
directories: { output: unsigned ? buildPaths.unsignedArtifacts : buildPaths.artifacts },
@@ -159,8 +174,10 @@ export function createElectronBuilderConfig(
await writeMacOSAppUpdateConfig(resourcesDir, resolveMacOSAppUpdateFeed(context.packager.config.publish),
context.packager.appInfo.updaterCacheDirName)
}
// The bundled runtime declares whichever version prepared it: the product version for an ordinary
// release, and a rewritten one for installed-update qualification.
await verifyDesktopRuntime(buildPaths.dsh,
context.packager.appInfo.version, { platform: resolvedPlatform, arch: resolvedArch })
preparedRuntimeVersion ?? productVersion, { platform: resolvedPlatform, arch: resolvedArch })
// Unsigned Windows builds skip electron-builder's afterSign hook.
if (packagesWindows && unsigned) await verifyWindowsAsarUnpack(buildPaths.dsh, resourcesDir, windowsCode)
},
@@ -23,7 +23,7 @@ export async function createInstalledUpdateBuilderConfig(manifest: string, versi
const dsh = join(run.root, version, 'dsh')
await verifyDesktopRuntime(dsh, version, { platform: 'win32', arch: 'x64' })
const config = createElectronBuilderConfig({ ...environment, DSH_DESKTOP_APP_ID: run.appId,
DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: '0' }, 'win32', 'x64', dsh)
DSH_DESKTOP_TARGET_PLATFORM: 'win32', DSH_DESKTOP_TARGET_ARCH: 'x64', DSH_DESKTOP_UNSIGNED: '0' }, 'win32', 'x64', dsh, version)
return { ...config,
productName: run.productName,
directories: { ...config.directories, output: join(run.root, version, 'installer') },
+15 -1
View File
@@ -2,14 +2,22 @@
!define INSTALLER_SOURCE_DIR "${__FILEDIR__}\..\installer"
!define /ifndef INSTALLER_BUILD_DIR "${__FILEDIR__}\..\.desktop-build\targets\win-x64\installer-ui"
!ifndef BUILD_UNINSTALLER
ManifestDPIAware true
!ifndef BUILD_UNINSTALLER
!define MUI_CUSTOMFUNCTION_GUIINIT InstallerGuiInit
!endif
!macro customHeader
!define /ifndef INSTALLER_STRINGS_FILE "${INSTALLER_SOURCE_DIR}\strings.nsh"
!include "${INSTALLER_STRINGS_FILE}"
!ifdef BUILD_UNINSTALLER
BrandingText " "
SetFont "Segoe UI" 9
!ifdef LANG_SIMPCHINESE
SetFont /LANG=${LANG_SIMPCHINESE} "Microsoft YaHei UI" 9
!endif
!include "${INSTALLER_SOURCE_DIR}\uninstall.nsh"
!endif
!ifndef BUILD_UNINSTALLER
!include "${INSTALLER_SOURCE_DIR}\theme.nsh"
!include "${INSTALLER_SOURCE_DIR}\pages.nsh"
@@ -74,6 +82,10 @@
Page custom InstallerWelcome InstallerWelcomeLeave
!macroend
!macro customUnInstall
Call un.CleanData
!macroend
!macro customPageAfterChangeDir
!define MUI_PAGE_CUSTOMFUNCTION_PRE InstallerBeforeInstall
!define MUI_PAGE_CUSTOMFUNCTION_SHOW InstallerProgressShow
@@ -163,6 +175,8 @@
${EndIf}
!insertmacro InstallerPublishStage 4
!insertmacro dshFinishDirectories
; Standard uninstall-entry metadata read by inventory tools; the upstream template records it only under its private key.
WriteRegStr SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" InstallLocation "$INSTDIR"
${If} $0 == 1
SetErrors
${Else}
+63 -8
View File
@@ -1,6 +1,6 @@
/** Build one release target with matching Electron and dsh architecture. */
import { execFileSync, spawn } from 'node:child_process'
import { spawn } from 'node:child_process'
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { parseArgs } from 'node:util'
import { join, resolve } from 'node:path'
@@ -19,6 +19,10 @@ import { macOSDownloadEnvironment, resolveMacOSPackageSettings } from './macos-p
import { packagingErrorDetails, packagingStep } from './packaging-step.mjs'
import { notarizeMacOS } from './notarize-macos.mjs'
import { resolveMacOSNotarizationEnvironment } from './desktop-release-environment.mjs'
import { DESKTOP_BUILD_VERSION_ENV, resolveDesktopBuildVersion, validateDesktopBuildVersion } from './desktop-build-version.mjs'
import { suggestDesktopBuildVersion } from './desktop-build-version-discovery.ts'
import { desktopBuildCommitEnvironment, readDesktopBuildCommit, resolveDesktopBuildCommit } from './desktop-build-commit.mjs'
import { requireDesktopToolchain } from './desktop-toolchain-preflight.ts'
import { withMacOSNotarizationProxy } from './macos-notarization-proxy.ts'
const APP_ROOT = resolve(import.meta.dirname, '..')
@@ -39,6 +43,9 @@ const DESKTOP_UPLOAD_CREDENTIAL_ENV_NAMES = new Set([
'DOWNLOAD_PROD_COS_SECRET_KEY',
])
/** `--build-version` value that numbers a build after the ones already taken. */
const AUTOMATIC_BUILD_VERSION = 'auto'
/** Fixed platform and architecture identifiers exposed by package scripts. */
export type DesktopPackageTargetName = 'mac-arm64' | 'mac-x64' | 'win-x64'
@@ -136,15 +143,19 @@ function writeReleaseRecord(
if (desktopVersion !== dshVersion) {
throw new Error(`desktop package: desktop version ${desktopVersion} does not match dsh version ${dshVersion}`)
}
const buildVersion = resolveDesktopBuildVersion(environment, dshVersion)
const packaged = resolveDesktopBuildCommit(environment)
const update = resolveDesktopAutoUpdateConfig(environment, target.platform, target.arch)
const recordPath = join(artifactsRoot, desktopBuildRecordFilename(target.name))
const temporaryPath = `${recordPath}.tmp`
writeFileSync(temporaryPath, `${JSON.stringify({
schemaVersion: 1,
target: target.name,
version: dshVersion,
version: buildVersion,
environment: update.environment,
publicUrl: update.publicUrl,
// Upload reads this to tag the commit a production release was packaged from.
...packaged === undefined ? {} : { commit: packaged.commit, dirty: packaged.dirty },
}, null, 2)}\n`)
renameSync(temporaryPath, recordPath)
}
@@ -186,6 +197,8 @@ interface DesktopPackageInvocation {
readonly prepareOnly: boolean
readonly unsigned: boolean
readonly check: boolean
/** Build identifier to publish under, when this build does not publish the product version. */
readonly requestedBuildVersion: string | undefined
}
function hostTargetName(platform: NodeJS.Platform, arch: string): DesktopPackageTargetName {
@@ -207,25 +220,33 @@ export function parseDesktopPackageInvocation(
hostArch: string = process.arch,
): DesktopPackageInvocation {
const { values, positionals } = parseArgs({
args: [...argv],
// `pnpm run <script> -- --build-version x` forwards the separator itself, and the script's own
// preset arguments come first, so it can land anywhere; parseArgs would read the rest as targets.
args: argv.filter(argument => argument !== '--'),
allowPositionals: true,
options: {
dir: { type: 'boolean', default: false },
'prepare-only': { type: 'boolean', default: false },
unsigned: { type: 'boolean', default: false },
check: { type: 'boolean', default: false },
'build-version': { type: 'string' },
},
})
if (positionals.length > 1) throw new Error('desktop package: expected at most one target')
const name = positionals[0] ?? hostTargetName(hostPlatform, hostArch)
if (values.unsigned && name !== 'win-x64') throw new Error('desktop package: --unsigned requires win-x64')
if (values.unsigned && values['prepare-only']) throw new Error('desktop package: --unsigned cannot use --prepare-only')
const requestedBuildVersion = values['build-version']?.trim()
if (values['build-version'] !== undefined && (requestedBuildVersion === undefined || requestedBuildVersion === '')) {
throw new Error('desktop package: --build-version requires a value')
}
return {
target: resolveDesktopPackageTarget(name, hostPlatform, hostArch),
directory: values.dir,
prepareOnly: values['prepare-only'],
unsigned: values.unsigned,
check: values.check,
requestedBuildVersion,
}
}
@@ -285,21 +306,53 @@ function runPnpm(
})
}
/**
* Resolve the version one run publishes from what its command line asked for.
* @param invocation - Validated packaging request.
* @param productVersion - Version the manifests declare.
* @param environment - Release settings, which name the bucket automatic numbering reads.
* @returns The product version, the requested version, or the next free index for today.
*/
async function resolveRequestedBuildVersion(
invocation: DesktopPackageInvocation,
productVersion: string,
environment: NodeJS.ProcessEnv,
): Promise<string> {
const requested = invocation.requestedBuildVersion
if (requested === undefined) return productVersion
if (requested !== AUTOMATIC_BUILD_VERSION) return validateDesktopBuildVersion(requested, productVersion)
const paths = desktopTargetBuildPaths(invocation.target.name)
return suggestDesktopBuildVersion({
productVersion, target: invocation.target.name, environment,
// Unsigned builds land beside the signed output, so numbering has to read the directory this run writes.
artifactsRoot: invocation.unsigned ? paths.unsignedArtifacts : paths.artifacts,
})
}
async function main(): Promise<void> {
const invocation = parseDesktopPackageInvocation(process.argv.slice(2))
const { target } = invocation
const environment = loadDesktopPackageEnvironment(target.platform)
const productVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package')
// Release settings come from the target dotenv file alone, so the version this run publishes is an
// argument; the environment variable below only carries it to the child processes that build.
const buildVersion = await resolveRequestedBuildVersion(invocation, productVersion, environment)
environment[DESKTOP_BUILD_VERSION_ENV] = buildVersion
if (invocation.check) {
validateDesktopPackageEnvironment(environment, target, invocation)
process.stdout.write(`desktop package: ${target.name} local configuration valid; signing and notarization were not attempted\n`)
await requireDesktopToolchain(target.platform, environment)
process.stdout.write(`desktop package: ${target.name} would publish ${buildVersion}; local configuration and toolchain valid, signing and notarization were not attempted\n`)
return
}
process.stdout.write(`desktop package: ${target.name} publishes ${buildVersion}${buildVersion === productVersion ? '' : ` for product version ${productVersion}`}\n`)
const packaged = readDesktopBuildCommit(REPOSITORY_ROOT)
Object.assign(environment, desktopBuildCommitEnvironment(packaged))
const secrets = Object.entries(environment).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD|APPLE_ID/iu.test(name)).map(([, value]) => value ?? '')
const run = createPackagingRun(join(APP_ROOT, '.desktop-build', 'packaging-runs'), {
target: target.name, unsigned: invocation.unsigned, directory: invocation.directory, prepareOnly: invocation.prepareOnly,
version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'), node: process.version,
commit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: REPOSITORY_ROOT, encoding: 'utf8' }).trim(),
dirty: execFileSync('git', ['status', '--porcelain', '--untracked-files=normal'], { cwd: REPOSITORY_ROOT, encoding: 'utf8' }).trim() !== '',
version: buildVersion, productVersion, node: process.version,
commit: packaged.commit,
dirty: packaged.dirty,
}, { parallel: target.platform === 'darwin', secrets })
console.log(`DESKTOP_PACKAGING_RECORD ${run.directory}`)
const previousDirectory = process.env.DSH_DESKTOP_PACKAGING_RUN_DIR
@@ -307,6 +360,7 @@ async function main(): Promise<void> {
let success = false
try {
await packagingStep(run.directory, 'configuration', async () => { validateDesktopPackageEnvironment(environment, target, invocation) }, secrets)
await packagingStep(run.directory, 'toolchain', () => requireDesktopToolchain(target.platform, environment), secrets)
if (target.platform === 'darwin') {
const settings = resolveMacOSPackageSettings(environment)
recordPackagingEvent(run.directory, { type: 'macos-settings', packConcurrency: settings.packConcurrency,
@@ -427,7 +481,8 @@ export async function packageTarget(
await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv)
await withMacOSNotarizationProxy(mac?.notarizationProxy, () => packageMacOSArtifacts({
arch: target.arch,
version: packageVersion(join(APP_ROOT, 'package.json'), 'desktop package'),
// electron-builder named these artifacts after the published version, so locating them uses the same identifier.
version: resolveDesktopBuildVersion(environment, packageVersion(join(APP_ROOT, 'package.json'), 'desktop package')),
artifactsRoot: buildPaths.artifacts,
environment: electronBuilderEnv,
}, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)), undefined, undefined, proxyEvent)
@@ -14,11 +14,22 @@ $visualStudio = & $vswhere -latest -products '*' -requires Microsoft.VisualStudi
if (-not $visualStudio) { throw 'Visual Studio C++ Build Tools are missing.' }
$vcvars = Join-Path $visualStudio 'VC/Auxiliary/Build/vcvars32.bat'
$compileScript = Join-Path $output 'compile-frame.cmd'
$compileLines = @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /LD /MT /O1 /W4 /WX /EHsc "{0}" /Fo"{1}" /link /OUT:"{2}" /IMPLIB:"{3}" user32.lib comctl32.lib dwmapi.lib gdiplus.lib ole32.lib shell32.lib uuid.lib' -f $source, (Join-Path $output 'window-frame.obj'), $library, (Join-Path $output 'window-frame.lib')))
$compileLines = @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /LD /MT /O1 /W4 /WX /EHsc "{0}" /Fo"{1}" /link /OUT:"{2}" /IMPLIB:"{3}" user32.lib comctl32.lib dwmapi.lib gdiplus.lib ole32.lib shell32.lib uuid.lib advapi32.lib' -f $source, (Join-Path $output 'window-frame.obj'), $library, (Join-Path $output 'window-frame.lib')))
[IO.File]::WriteAllLines($compileScript, $compileLines, [Text.Encoding]::Default)
& $env:ComSpec /d /c $compileScript
if ($LASTEXITCODE -ne 0) { throw 'Native installer helper compilation failed.' }
if ($TestProgress -or $CompileProgressOnly) {
$cleanupSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-uninstall-data.cpp'))
# UAC installer detection demands elevation from unmanifested executables named after installation.
$cleanupExecutable = Join-Path $output 'data-cleanup-test.exe'
$cleanupScript = Join-Path $output 'compile-data-cleanup-test.cmd'
[IO.File]::WriteAllLines($cleanupScript, @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /MT /W4 /WX /EHsc "{0}" /Fo"{1}" /Fe"{2}" /link shell32.lib ole32.lib uuid.lib advapi32.lib' -f $cleanupSource, (Join-Path $output 'data-cleanup-test.obj'), $cleanupExecutable)), [Text.Encoding]::Default)
& $env:ComSpec /d /c $cleanupScript
if ($LASTEXITCODE -ne 0) { throw 'Uninstall data test compilation failed.' }
if ($TestProgress) {
& $cleanupExecutable (Join-Path $output ('cleanup-' + [Guid]::NewGuid().ToString('N')))
if ($LASTEXITCODE -ne 0) { throw 'Uninstall data regression failed.' }
}
$testSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-installer-progress.cpp'))
$testExecutable = Join-Path $output 'progress-test.exe'
$testScript = Join-Path $output 'compile-progress-test.cmd'
@@ -21,6 +21,9 @@ const { getMakeNsisPath } = require('app-builder-lib/out/toolsets/windows.js')
const guid = randomUUID()
const id = guid.replaceAll('-', '')
const productName = `Harness Installer Test ${id.slice(0, 8)}`
// Scoped like the shipped package so Electron user data nests under a scope directory; the scope is unique per run.
const packageName = `@harness-installer-test-${id.slice(0, 8)}/app-${id}`
const uninstallOnly = process.argv.includes('--uninstall-only')
const outputRoot = join(appRoot, '.desktop-build', 'installer-tests')
await mkdir(outputRoot, { recursive: true })
const output = await mkdtemp(join(outputRoot, 'run-'))
@@ -55,14 +58,19 @@ try {
join(appRoot, 'scripts', 'prepare-windows-installer.ps1'), '-OutputDirectory', join(output, 'ui'), '-CompileProgressOnly'], childOptions)
const progressTest = join(output, 'ui', 'progress-test.exe')
const presentationTest = join(output, 'ui', 'presentation-test.exe')
const cleanupTest = join(output, 'ui', 'data-cleanup-test.exe')
if (sign) {
await sign({ path: progressTest, hash: 'sha256', isNest: false })
await sign({ path: presentationTest, hash: 'sha256', isNest: false })
await sign({ path: cleanupTest, hash: 'sha256', isNest: false })
await sign({ path: join(output, 'ui', 'window-frame.dll'), hash: 'sha256', isNest: false })
installWindowsNsisBootstrapSigner({ sign })
}
if (!uninstallOnly) {
await execute(progressTest, [], childOptions)
await execute(presentationTest, [], childOptions)
}
await execute(cleanupTest, [join(output, 'ui', `cleanup-${id}`)], childOptions)
const payloadSource = join(output, 'payload.nsi')
await writeFile(payloadSource, `Unicode true
RequestExecutionLevel user
@@ -81,12 +89,12 @@ SectionEnd
await execute(compiler.path, ['/V2', payloadSource], { ...childOptions, env: { ...childOptions.env, ...compiler.env } })
if (sign) await sign({ path: join(payload, `${productName}.exe`), hash: 'sha256', isNest: false })
const sourceStrings = await readFile(join(appRoot, 'installer', 'strings.nsh'), 'utf8')
for (const language of ['en_US', 'zh_CN']) {
const config = createElectronBuilderConfig()
if (sign) {
config.win.forceCodeSigning = true
config.win.signtoolOptions.sign = sign
}
for (const language of ['en_US', 'zh_CN']) {
const languageOutput = join(output, language)
await mkdir(languageOutput)
const strings = join(languageOutput, 'strings.nsh')
@@ -96,12 +104,16 @@ SectionEnd
const include = join(languageOutput, 'include.nsh')
await writeFile(include, `!define INSTALLER_BUILD_DIR "${join(output, 'ui')}"\n!define INSTALLER_STRINGS_FILE "${strings}"\n!include "${join(appRoot, 'scripts', 'installer.nsh')}"\n`)
await build({ projectDir: appRoot, prepackaged: payload, targets: Platform.WINDOWS.createTarget(['nsis'], Arch.x64), publish: 'never',
config: { ...config, productName, artifactName: 'installer-test.exe', directories: { output: languageOutput },
config: { ...config, productName, extraMetadata: { ...config.extraMetadata, name: packageName },
artifactName: 'installer-test.exe', directories: { output: languageOutput },
nsis: { ...config.nsis, guid, include, installerLanguages: [language] }, beforeBuild: undefined, afterPack: undefined, afterSign: undefined, artifactBuildCompleted: undefined },
})
if (process.argv.includes('--compile-only')) continue
const result = await execute('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
join(appRoot, 'tests', 'windows-installer-smoke.ps1'), '-Installer', join(languageOutput, 'installer-test.exe'),
'-ProductName', productName, '-RegistryKey', guid, '-OutputDirectory', languageOutput], childOptions)
join(appRoot, 'tests', uninstallOnly ? 'windows-uninstall-smoke.ps1' : 'windows-installer-smoke.ps1'),
'-Installer', join(languageOutput, 'installer-test.exe'),
'-ProductName', productName, '-RegistryKey', guid, '-OutputDirectory', languageOutput,
...uninstallOnly ? ['-Language', languageId, '-PackageName', packageName] : []], childOptions)
process.stdout.write(`${language}\n${result.stdout}`)
}
succeeded = true
+29 -1
View File
@@ -6,7 +6,8 @@ import type { DesktopPackageTargetName } from './package-target.ts'
import { createDesktopCos } from './desktop-cos.ts'
import { resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs'
import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
import { createDesktopUploadPlan } from './desktop-upload-plan.ts'
import { createDesktopUploadPlan, type DesktopUploadPlan } from './desktop-upload-plan.ts'
import { desktopReleaseTag, tagDesktopRelease } from './desktop-release-tag.ts'
import { uploadDesktopRelease } from './desktop-upload-run.ts'
const SUPPORTED_TARGETS = new Set<DesktopPackageTargetName>(['mac-arm64', 'mac-x64', 'win-x64'])
@@ -85,6 +86,33 @@ async function main(): Promise<void> {
})
process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`)
await uploadDesktopRelease(plan, cos, resolve(import.meta.dirname, '../.desktop-build/upload-records'))
if (plan.environment === 'production') recordProductionRelease(plan)
}
/**
* Tag the commit an external release was packaged from, after its artifacts are public.
* @param plan Completed production upload.
*/
function recordProductionRelease(plan: DesktopUploadPlan): void {
const tag = desktopReleaseTag(plan.version)
if (plan.commit === undefined) {
process.stdout.write(`desktop upload: this package was built before builds recorded their commit; tag it by hand as ${tag}\n`)
return
}
if (plan.dirty === true) {
// A tag would name a commit whose tree is not what the users received.
process.stderr.write(`desktop upload: packaged from ${plan.commit} with uncommitted changes, so ${tag} was not created\n`)
return
}
const result = tagDesktopRelease({ version: plan.version, commit: plan.commit, repositoryRoot: resolve(import.meta.dirname, '../../..') })
if (result.status === 'failed') {
process.stderr.write(`desktop upload: the release is published but ${result.tag} was not recorded: ${result.detail ?? 'unknown error'}\n`)
if (result.recovery !== undefined) {
process.stderr.write(`desktop upload: record it with: ${result.recovery.join(' && ')}\n`)
}
return
}
process.stdout.write(`desktop upload: ${result.tag} ${result.status === 'created' ? 'records' : 'already recorded'} ${plan.commit}\n`)
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
@@ -14,5 +14,12 @@ export function directoryInstallSection(source: string): string
*/
export function directoryInstallerExits(source: string): string
/**
* Keep user-data removal in the native helper, which refuses unsafe roots and never follows links.
* @param source - Pinned upstream uninstaller source.
* @returns Uninstaller with long-path application removal and no upstream RMDir data removal.
*/
export function directoryUninstaller(source: string): string
/** Install the build-only adapter while retaining signed uninstaller generation. */
export function installWindowsDirectoryInstaller(): void
@@ -78,9 +78,21 @@ export function installWindowsDirectoryInstaller() {
adapted = replaceOnce(adapted, `!include "${helper}"`, `!include "${path}"`)
}
const uninstaller = join(directory, 'uninstaller.nsh')
await writeFile(uninstaller, replaceOnce(await readFile(join(templates, 'uninstaller.nsh'), 'utf8'),
'RMDir /r $INSTDIR', 'RMDir /r "\\\\?\\$INSTDIR"'))
await writeFile(uninstaller, directoryUninstaller(await readFile(join(templates, 'uninstaller.nsh'), 'utf8')))
adapted = replaceOnce(adapted, '!include "uninstaller.nsh"', `!include "${uninstaller}"`)
return `!define DSH_SEVENZIP_PATH "${tool}"\n!define DSH_SEVENZIP_LICENSE_DIR "${dirname(dirname(sourceTool))}"\n${await compute.call(this, adapted, ...args)}`
return `!define DSH_UPDATER_CACHE_NAME "${this.packager.appInfo.updaterCacheDirName}"\n!define DSH_SEVENZIP_PATH "${tool}"\n!define DSH_SEVENZIP_LICENSE_DIR "${dirname(dirname(sourceTool))}"\n${await compute.call(this, adapted, ...args)}`
}
}
/**
* Keep user-data removal in the native helper, which refuses unsafe roots and never follows links.
* @param {string} source - Pinned upstream uninstaller source.
* @returns {string} Uninstaller with long-path application removal and no upstream RMDir data removal.
*/
export function directoryUninstaller(source) {
const normalized = source.replaceAll('\r\n', '\n')
const start = normalized.indexOf(' Var /GLOBAL isDeleteAppData\n')
const end = normalized.indexOf(' DeleteRegKey SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}"', start)
if (start < 0 || end < 0) throw new Error('NSIS uninstaller data removal template changed')
return replaceOnce(normalized.slice(0, start) + normalized.slice(end), 'RMDir /r $INSTDIR', 'RMDir /r "\\\\?\\$INSTDIR"')
}
+4 -3
View File
@@ -181,12 +181,13 @@ export function readDesktopRuntime(root: string): DesktopRuntimeDescriptor {
/**
* Verify every packaged runtime file against its recorded bytes and permissions at build time.
* @param root - Materialized runtime resources.
* @param electronVersion - Expected shell version.
* @param expectedVersion - Version the bundled runtime must declare: the product version ordinarily,
* and a rewritten one for installed-update qualification.
* @param target - Required execution target; defaults to the current process.
* @returns Validated runtime descriptor.
*/
export async function verifyDesktopRuntime(
root: string, electronVersion: string, target: { platform: NodeJS.Platform; arch: string } = process,
root: string, expectedVersion: string, target: { platform: NodeJS.Platform; arch: string } = process,
): Promise<DesktopRuntimeDescriptor> {
const descriptor = readDesktopRuntime(root)
// readDesktopRuntime preserves the disk schema value without validating release compatibility.
@@ -195,7 +196,7 @@ export async function verifyDesktopRuntime(
throw new Error('desktop runtime: invalid descriptor or incompatible platform/architecture')
}
const release = parseDesktopRelease(descriptor.release)
if (release.version !== electronVersion) throw new Error(`desktop runtime: ${release.version} does not match Electron ${electronVersion}`)
if (release.version !== expectedVersion) throw new Error(`desktop runtime: bundled ${release.version} is not the expected ${expectedVersion}`)
for (const entry of descriptor.sharedPackages) {
const manifest: unknown = JSON.parse(readFileSync(join(runtimePath(root, entry.path), 'package.json'), 'utf8'))
if (!record(manifest) || manifest.name !== entry.name || manifest.version !== entry.version) {
@@ -0,0 +1,83 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
DESKTOP_BUILD_COMMIT_ENV,
DESKTOP_BUILD_DIRTY_ENV,
desktopBuildCommitEnvironment,
readDesktopBuildCommit,
resolveDesktopBuildCommit,
} from '../scripts/desktop-build-commit.mjs'
import { probeDesktopToolchain } from '../scripts/desktop-toolchain-preflight.ts'
const COMMIT = '0123456789abcdef0123456789abcdef01234567'
const directories: string[] = []
afterEach(async () => {
for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true })
})
describe('desktop build commit', () => {
it('reads nothing outside a packaging run', () => {
expect(resolveDesktopBuildCommit({})).toBeUndefined()
expect(resolveDesktopBuildCommit({ [DESKTOP_BUILD_COMMIT_ENV]: ' ' })).toBeUndefined()
})
it('round-trips a commit and its tree state through a child environment', () => {
for (const dirty of [true, false]) {
expect(resolveDesktopBuildCommit(desktopBuildCommitEnvironment({ commit: COMMIT, dirty })))
.toEqual({ commit: COMMIT, dirty })
}
})
it.each(['abc', `${COMMIT}0`, 'Z'.repeat(40)])('rejects %j, which is not a commit hash', (commit) => {
expect(() => resolveDesktopBuildCommit({ [DESKTOP_BUILD_COMMIT_ENV]: commit })).toThrow(/must be a commit hash/u)
})
it('rejects a tree state that is neither 0 nor 1', () => {
expect(() => resolveDesktopBuildCommit({ [DESKTOP_BUILD_COMMIT_ENV]: COMMIT, [DESKTOP_BUILD_DIRTY_ENV]: 'yes' }))
.toThrow(/must be 0 or 1/u)
})
it('reads the checkout this repository is in', () => {
const packaged = readDesktopBuildCommit(join(import.meta.dirname, '..', '..', '..'))
expect(packaged.commit).toMatch(/^[0-9a-f]{40}$/u)
expect(typeof packaged.dirty).toBe('boolean')
})
})
describe('desktop toolchain preflight', () => {
it('passes on a host that can read archives', async () => {
expect(await probeDesktopToolchain('darwin', {})).toEqual([])
})
it('reports the archive reader when it cannot run', async () => {
const empty = await mkdtemp(join(tmpdir(), 'dsh-no-tools-'))
directories.push(empty)
const saved = { PATH: process.env.PATH, Path: process.env.Path }
try {
Object.assign(process.env, { PATH: empty, Path: empty })
const failures = await probeDesktopToolchain('darwin', {})
expect(failures.map(failure => failure.tool)).toEqual(['tar'])
expect(failures[0]?.detail).not.toBe('')
}
finally {
Object.assign(process.env, saved)
}
})
it('reports the Windows installer compiler when the host cannot locate Visual Studio', async () => {
const failures = await probeDesktopToolchain('win32', {})
expect(failures.map(failure => failure.tool)).toContain('vswhere')
expect(failures.find(failure => failure.tool === 'vswhere')?.detail).toContain('ProgramFiles(x86)')
})
it('writes nothing into the working directory while probing', async () => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-probe-cwd-'))
directories.push(directory)
await writeFile(join(directory, 'sentinel'), '')
await probeDesktopToolchain('darwin', {})
expect(await import('node:fs/promises').then(async fs => fs.readdir(directory))).toEqual(['sentinel'])
})
})
@@ -0,0 +1,73 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
desktopBuildDateSegment,
suggestDesktopBuildVersion,
} from '../scripts/desktop-build-version-discovery.ts'
const PRERELEASE = '0.1.6-alpha.2'
const STABLE = '0.1.6'
const DATE = '20260921'
const directories: string[] = []
async function artifactsWith(names: readonly string[]): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'dsh-discovery-'))
directories.push(directory)
for (const name of names) await writeFile(join(directory, name), '')
return directory
}
afterEach(async () => {
for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true })
})
describe('desktop build version discovery', () => {
it('formats the date segment in the build host time zone', () => {
expect(desktopBuildDateSegment(new Date(2026, 8, 21))).toBe('20260921')
expect(desktopBuildDateSegment(new Date(2026, 0, 5))).toBe('20260105')
})
it('starts at one when nothing is taken', async () => {
const artifactsRoot = await artifactsWith([])
await expect(suggestDesktopBuildVersion({ productVersion: PRERELEASE, target: 'win-x64', environment: {}, date: DATE, artifactsRoot }))
.resolves.toBe(`${PRERELEASE}.${DATE}.1`)
})
it('numbers a stable product version under the documented test prerelease', async () => {
const artifactsRoot = await artifactsWith([`deepseek-harness-${STABLE}-test.${DATE}.4-win-x64.exe`])
await expect(suggestDesktopBuildVersion({ productVersion: STABLE, target: 'win-x64', environment: {}, date: DATE, artifactsRoot }))
.resolves.toBe(`${STABLE}-test.${DATE}.5`)
})
it('numbers after the highest local artifact for the same date', async () => {
const artifactsRoot = await artifactsWith([
`deepseek-harness-${PRERELEASE}.${DATE}.1-win-x64.exe`,
`deepseek-harness-${PRERELEASE}.${DATE}.2-win-x64.exe`,
`deepseek-harness-${PRERELEASE}.${DATE}.10-win-x64.exe`,
])
await expect(suggestDesktopBuildVersion({ productVersion: PRERELEASE, target: 'win-x64', environment: {}, date: DATE, artifactsRoot }))
.resolves.toBe(`${PRERELEASE}.${DATE}.11`)
})
it('ignores artifacts from another date or product version', async () => {
const artifactsRoot = await artifactsWith([
`deepseek-harness-${PRERELEASE}.20260920.7-win-x64.exe`,
`deepseek-harness-0.1.5-alpha.1.${DATE}.9-win-x64.exe`,
`deepseek-harness-${PRERELEASE}-win-x64.exe`,
'unrelated.exe',
])
await expect(suggestDesktopBuildVersion({ productVersion: PRERELEASE, target: 'win-x64', environment: {}, date: DATE, artifactsRoot }))
.resolves.toBe(`${PRERELEASE}.${DATE}.1`)
})
it('reads macOS artifact names too', async () => {
const artifactsRoot = await artifactsWith([
`deepseek-harness-${PRERELEASE}.${DATE}.3-mac-arm64.dmg`,
`deepseek-harness-${PRERELEASE}.${DATE}.3-mac-arm64.zip`,
])
await expect(suggestDesktopBuildVersion({ productVersion: PRERELEASE, target: 'mac-arm64', environment: {}, date: DATE, artifactsRoot }))
.resolves.toBe(`${PRERELEASE}.${DATE}.4`)
})
})
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import {
DESKTOP_BUILD_VERSION_ENV,
desktopBuildVersionPrefix,
resolveDesktopBuildVersion,
validateDesktopBuildVersion,
} from '../scripts/desktop-build-version.mjs'
const PRERELEASE = '0.1.6-alpha.2'
const STABLE = '0.1.6'
describe('desktop build version', () => {
it('publishes the product version when no build version is present', () => {
expect(resolveDesktopBuildVersion({}, PRERELEASE)).toBe(PRERELEASE)
expect(resolveDesktopBuildVersion({ [DESKTOP_BUILD_VERSION_ENV]: ' ' }, PRERELEASE)).toBe(PRERELEASE)
})
it.each(['0.1.6-alpha.2.20260921.1', '0.1.6-alpha.2.20260921.12'])(
'accepts %s, which outranks the prerelease base it extends', (buildVersion) => {
expect(resolveDesktopBuildVersion({ [DESKTOP_BUILD_VERSION_ENV]: buildVersion }, PRERELEASE)).toBe(buildVersion)
expect(validateDesktopBuildVersion(buildVersion, PRERELEASE)).toBe(buildVersion)
})
it('accepts the documented test form on a stable base', () => {
// 0.1.6-test.x ranks below 0.1.6, which is why this form only ever reaches a test feed.
expect(validateDesktopBuildVersion('0.1.6-test.20260921.1', STABLE)).toBe('0.1.6-test.20260921.1')
})
it.each([PRERELEASE, STABLE])('accepts %s, the product version itself', (productVersion) => {
expect(validateDesktopBuildVersion(productVersion, productVersion)).toBe(productVersion)
})
it.each(['', 'nightly', '0.1.6-alpha.2.'])(
'rejects %j as a version', (buildVersion) => {
expect(() => validateDesktopBuildVersion(buildVersion, PRERELEASE)).toThrow(/is not a version/u)
})
it('normalizes what the artifacts carry, so validation and publication agree', () => {
expect(validateDesktopBuildVersion('v0.1.6-alpha.2.1', PRERELEASE)).toBe('0.1.6-alpha.2.1')
})
it('rejects build metadata, which does not affect updater precedence', () => {
expect(() => validateDesktopBuildVersion('0.1.6-alpha.2.1+build', PRERELEASE)).toThrow(/build metadata/u)
})
it.each(['0.1.7-alpha.2.20260921.1', '0.2.6-alpha.2.20260921.1'])(
'rejects %s because it leaves the product release numbers', (buildVersion) => {
expect(() => validateDesktopBuildVersion(buildVersion, PRERELEASE)).toThrow(/must extend product version/u)
})
it.each(['0.1.6-beta.2.20260921.1', '0.1.6-alpha.3', '0.1.6'])(
'rejects %s because it does not extend the product prerelease', (buildVersion) => {
expect(() => validateDesktopBuildVersion(buildVersion, PRERELEASE)).toThrow(/must extend 0\.1\.6-alpha\.2 as/u)
})
it.each(['0.1.6-20260921.1', '0.1.6-nightly.20260921.1'])(
'rejects %s on a stable base, which the test form has to open', (buildVersion) => {
expect(() => validateDesktopBuildVersion(buildVersion, STABLE)).toThrow(/must extend 0\.1\.6 as 0\.1\.6-test\./u)
})
it.each([[PRERELEASE, '0.1.6-alpha.2.'], [STABLE, '0.1.6-test.']])(
'opens %s build versions with %s', (productVersion, prefix) => {
expect(desktopBuildVersionPrefix(productVersion)).toBe(prefix)
})
})
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import { desktopReleaseTag, tagDesktopRelease } from '../scripts/desktop-release-tag.ts'
const COMMIT = '0'.repeat(40)
const OTHER = '1'.repeat(40)
const VERSION = '0.1.6-alpha.2'
function recorder(responses: Record<string, string | Error>): {
readonly run: (command: string, args: readonly string[]) => string
readonly calls: string[][]
} {
const calls: string[][] = []
return {
calls,
run: (command, args) => {
calls.push([command, ...args])
const response = responses[args[0] ?? '']
if (response instanceof Error) throw response
return response ?? ''
},
}
}
describe('desktop release tag', () => {
it('names a tag distinct from the npm family tags', () => {
expect(desktopReleaseTag(VERSION)).toBe(`desktop-v${VERSION}`)
})
it('creates and pushes a tag for the packaged commit', () => {
const { run, calls } = recorder({ tag: '' })
expect(tagDesktopRelease({ version: VERSION, commit: COMMIT, repositoryRoot: '.', run }))
.toEqual({ tag: `desktop-v${VERSION}`, status: 'created' })
expect(calls).toEqual([
['git', 'tag', '--list', `desktop-v${VERSION}`],
['git', 'tag', `desktop-v${VERSION}`, COMMIT],
['git', 'push', 'origin', `desktop-v${VERSION}`],
])
})
it('pushes an existing tag that already names the uploaded commit', () => {
const { run, calls } = recorder({ tag: `desktop-v${VERSION}`, 'rev-list': COMMIT })
expect(tagDesktopRelease({ version: VERSION, commit: COMMIT, repositoryRoot: '.', run }))
.toEqual({ tag: `desktop-v${VERSION}`, status: 'present' })
expect(calls.at(-1)).toEqual(['git', 'push', 'origin', `desktop-v${VERSION}`])
})
it('refuses to move a tag that names another commit', () => {
const { run, calls } = recorder({ tag: `desktop-v${VERSION}`, 'rev-list': OTHER })
const result = tagDesktopRelease({ version: VERSION, commit: COMMIT, repositoryRoot: '.', run })
expect(result.status).toBe('failed')
expect(result.detail).toMatch(new RegExp(`already names ${OTHER}`, 'u'))
expect(calls.some(call => call.includes('push'))).toBe(false)
})
it('reports a git failure instead of throwing, because the release is already public', () => {
const { run } = recorder({ tag: new Error('git unavailable') })
expect(tagDesktopRelease({ version: VERSION, commit: COMMIT, repositoryRoot: '.', run }))
.toMatchObject({ status: 'failed', detail: 'git unavailable' })
})
it('recovers a failed push without repeating the tag that already exists', () => {
// `git tag` would fail with "already exists" and short-circuit a combined command, leaving the push undone.
const { run } = recorder({ tag: '', push: new Error('remote rejected') })
expect(tagDesktopRelease({ version: VERSION, commit: COMMIT, repositoryRoot: '.', run }))
.toMatchObject({ status: 'failed', recovery: [`git push origin desktop-v${VERSION}`] })
})
it('recovers a failed tag by creating it before pushing', () => {
const { run } = recorder({ tag: new Error('cannot write ref') })
expect(tagDesktopRelease({ version: VERSION, commit: COMMIT, repositoryRoot: '.', run }))
.toMatchObject({ recovery: [`git tag desktop-v${VERSION} ${COMMIT}`, `git push origin desktop-v${VERSION}`] })
})
it('pushes to a named remote', () => {
const { run, calls } = recorder({ tag: '' })
tagDesktopRelease({ version: VERSION, commit: COMMIT, repositoryRoot: '.', remote: 'upstream', run })
expect(calls.at(-1)).toEqual(['git', 'push', 'upstream', `desktop-v${VERSION}`])
})
})
@@ -0,0 +1,3 @@
{
"cases": ["interactive", "silent", "upgrade", "replaced", "home-inside"]
}
@@ -34,6 +34,7 @@ class RosterOnlyJobRegistry extends JobRegistry {
readAt(): never { throw new Error('unsupported') }
kill(): never { throw new Error('unsupported') }
wait(): never { throw new Error('unsupported') }
remove(): never { throw new Error('unsupported') }
attachController(): () => void { return () => {} }
}
@@ -12,6 +12,8 @@ vi.mock('../scripts/desktop-package-environment.mjs', () => ({
vi.mock('../scripts/macos-signing-keychain.mjs', () => ({
withMacOSSigningKeychain: async (_environment: object, action: (environment: object) => Promise<unknown>) => action({}),
}))
// This test fakes the host platform, so the real probes would report the absent Windows compilers rather than the failure under test.
vi.mock('../scripts/desktop-toolchain-preflight.ts', () => ({ requireDesktopToolchain: async () => {} }))
vi.mock('node:fs', async importOriginal => ({
...await importOriginal<typeof import('node:fs')>(),
rmSync: () => { throw new AggregateError([new Error('credential-sentinel')], 'restore:mac-proxy recovery required') },
@@ -51,6 +53,7 @@ it.each(['win32', 'darwin'] as const)('records and prints redacted parent failur
expect(JSON.parse(await readFile(join(state.directory, 'result.json'), 'utf8'))).toMatchObject({
success: false, stages: [
{ stage: 'configuration', success: true },
{ stage: 'toolchain', success: true },
{ stage: platform === 'win32' ? 'windows-package' : 'macos-package', success: false },
],
})
+14
View File
@@ -44,6 +44,20 @@ describe('desktop package target', () => {
.toThrow(/at most one target/u)
})
it('parses a build version, including the separator a pnpm run script forwards', () => {
expect(parseDesktopPackageInvocation(['mac-arm64'], 'darwin', 'arm64').requestedBuildVersion).toBeUndefined()
expect(parseDesktopPackageInvocation(['mac-arm64', '--build-version', '0.1.6-alpha.2.20260921.1'], 'darwin', 'arm64')
.requestedBuildVersion).toBe('0.1.6-alpha.2.20260921.1')
expect(parseDesktopPackageInvocation(['mac-arm64', '--build-version', 'auto'], 'darwin', 'arm64')
.requestedBuildVersion).toBe('auto')
// A run script's preset arguments come first, so pnpm forwards the separator after the target.
expect(parseDesktopPackageInvocation(['mac-arm64', '--', '--build-version', 'auto'], 'darwin', 'arm64'))
.toMatchObject({ requestedBuildVersion: 'auto', target: { name: 'mac-arm64' } })
expect(parseDesktopPackageInvocation(['--', '--check'], 'darwin', 'arm64').check).toBe(true)
expect(() => parseDesktopPackageInvocation(['mac-arm64', '--build-version', ' '], 'darwin', 'arm64'))
.toThrow(/--build-version requires a value/u)
})
it('keeps electron-builder publishing disabled for the separate validated upload', () => {
const target = resolveDesktopPackageTarget('mac-arm64', 'darwin', 'arm64')
expect(desktopElectronBuilderArguments(target, false)).toEqual([
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from 'vitest'
const { verifyDesktopRuntime } = vi.hoisted(() => ({
verifyDesktopRuntime: vi.fn<(root: string, expected: string) => Promise<void>>(async () => undefined),
}))
// The hook imports the built tree, which a clean checkout has not produced; this is the path it resolves.
vi.mock('/apps/desktop/lib/types/runtime-tree.js', () => ({ verifyDesktopRuntime }))
vi.mock('../scripts/windows-asar-unpack.mjs', async importOriginal => ({
...await importOriginal<typeof import('../scripts/windows-asar-unpack.mjs')>(),
verifyWindowsAsarUnpack: async () => undefined,
}))
const ENVIRONMENT = {
DSH_DESKTOP_APP_ID: 'com.example.installer',
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
DSH_DESKTOP_TARGET_PLATFORM: 'win32',
DSH_DESKTOP_TARGET_ARCH: 'x64',
DSH_DESKTOP_UNSIGNED: '1',
DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com',
DOWNLOAD_TEST_RELEASE_ID: '0123456789abcdef0123456789abcdef',
}
const CONTEXT = { appOutDir: 'out', packager: { getResourcesDir: () => 'out/resources' } }
/**
* Run the packaging hook that verifies the bundled runtime.
* @returns The version that hook required the runtime to declare.
*/
async function requiredRuntimeVersion(preparedRuntime?: string, preparedRuntimeVersion?: string): Promise<unknown> {
verifyDesktopRuntime.mockClear()
const { createElectronBuilderConfig } = await import('../scripts/electron-builder-config.mjs')
const config = createElectronBuilderConfig(ENVIRONMENT, 'win32', 'x64', preparedRuntime, preparedRuntimeVersion)
await config.afterPack(CONTEXT as never)
return verifyDesktopRuntime.mock.calls[0]?.[1]
}
describe('packaged runtime verification', () => {
it('requires the product version when the target tree supplies the runtime', async () => {
const productVersion = (JSON.parse(
await import('node:fs/promises').then(async fs => fs.readFile(new URL('../package.json', import.meta.url), 'utf8')),
) as { version: string }).version
expect(await requiredRuntimeVersion()).toBe(productVersion)
})
it('requires the version installed-update qualification wrote into its private runtime', async () => {
// Qualification rewrites the runtime's own version, so comparing against the product version would always fail.
expect(await requiredRuntimeVersion('/qualification/dsh', '0.1.6-alpha.2.20260921.1')).toBe('0.1.6-alpha.2.20260921.1')
})
it('does not let a build version change what the bundled runtime must declare', async () => {
const productVersion = (JSON.parse(
await import('node:fs/promises').then(async fs => fs.readFile(new URL('../package.json', import.meta.url), 'utf8')),
) as { version: string }).version
const { createElectronBuilderConfig } = await import('../scripts/electron-builder-config.mjs')
verifyDesktopRuntime.mockClear()
const config = createElectronBuilderConfig(
{ ...ENVIRONMENT, DSH_DESKTOP_BUILD_VERSION: `${productVersion}.20260921.1` }, 'win32', 'x64')
expect(config.extraMetadata).toMatchObject({ version: `${productVersion}.20260921.1` })
await config.afterPack(CONTEXT as never)
expect(verifyDesktopRuntime.mock.calls[0]?.[1]).toBe(productVersion)
})
})
+2 -2
View File
@@ -54,10 +54,10 @@ it.each(['missing', 'directory'])('checks a %s Host entry only during build veri
expect(readDesktopRuntime(dsh).release.version).toBe('1.0.0')
await expect(verifyDesktopRuntime(dsh, '1.0.0')).rejects.toThrow(/integrity/u)
})
it('checks the shell version only during build verification', async () => {
it('checks the bundled version only during build verification', async () => {
const dsh = join(fixture(), 'dsh')
expect(readDesktopRuntime(dsh).release.version).toBe('1.0.0')
await expect(verifyDesktopRuntime(dsh, '2.0.0')).rejects.toThrow(/does not match Electron/u)
await expect(verifyDesktopRuntime(dsh, '2.0.0')).rejects.toThrow(/bundled 1\.0\.0 is not the expected 2\.0\.0/u)
})
it.each([
{ schemaVersion: 2 },
@@ -3,12 +3,26 @@ import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { expect, it } from 'vitest'
import { directoryInstallerExits, directoryInstallSection } from '../scripts/windows-directory-installer.mjs'
import { directoryInstallerExits, directoryInstallSection, directoryUninstaller } from '../scripts/windows-directory-installer.mjs'
const require = createRequire(import.meta.url)
const section = readFileSync(join(dirname(require.resolve('app-builder-lib/package.json')),
'templates/nsis/installSection.nsh'), 'utf8')
it('keeps data cleanup out of the upstream template while retaining application removal and registration cleanup', () => {
const source = readFileSync(join(dirname(require.resolve('app-builder-lib/package.json')), 'templates/nsis/uninstaller.nsh'), 'utf8')
const adapted = directoryUninstaller(source)
expect(adapted).not.toContain('--delete-app-data')
expect(adapted).not.toContain('RMDir /r "$APPDATA')
expect(adapted).toContain('!insertmacro customUnInstall')
expect(adapted).toContain('DeleteRegKey SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}"')
expect(adapted).toContain('DeleteRegKey SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY_2}"')
expect(directoryUninstaller(source.replaceAll('\n', '\r\n'))).toBe(adapted)
expect(adapted).toContain('RMDir /r "\\\\?\\$INSTDIR"')
expect(() => directoryUninstaller(source.replace(' Var /GLOBAL isDeleteAppData\n', ''))).toThrow('template changed')
expect(() => directoryUninstaller(source.replace(' DeleteRegKey SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}"', ''))).toThrow('template changed')
})
it.each(['allowOnlyOneInstallerInstance.nsh', 'installUtil.nsh'])('cleans staged files before %s exits', (helper) => {
const source = readFileSync(join(dirname(require.resolve('app-builder-lib/package.json')), 'templates/nsis/include', helper), 'utf8')
const exits = source.match(/^\s*Quit\s*$/gm) ?? []
+4 -2
View File
@@ -197,8 +197,10 @@ public static class InstallerCapture {
RedrawWindow(window, IntPtr.Zero, IntPtr.Zero, 0x181);
}
public static string Save(IntPtr window, string path) {
Reveal(window);
public static string Save(IntPtr window, string path) { return SaveNative(window, path, true); }
public static string SaveNative(IntPtr window, string path, bool waitForInstaller) {
if (waitForInstaller) Reveal(window);
Rect rect;
if (!GetWindowRect(window, out rect)) throw new InvalidOperationException("Could not read preview bounds");
int width = rect.Right - rect.Left, height = rect.Bottom - rect.Top;
@@ -0,0 +1,91 @@
// Native cleanup regressions operate only in the caller's private test directory.
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#define UNICODE
#include <windows.h>
#include <cassert>
#include "../installer/uninstall-data.h"
static void File(const std::wstring& path, DWORD attributes = FILE_ATTRIBUTE_NORMAL) {
HANDLE file = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, attributes, nullptr);
assert(file != INVALID_HANDLE_VALUE);
DWORD count;
assert(WriteFile(file, "retained", 8, &count, nullptr));
CloseHandle(file);
}
static bool Exists(const std::wstring& path) { return GetFileAttributesW(path.c_str()) != INVALID_FILE_ATTRIBUTES; }
int wmain(int argc, wchar_t** argv) {
assert(argc == 2);
const std::wstring root = argv[1];
const std::wstring installation = root + L"\\application";
const std::wstring data = root + L"\\data";
const std::wstring cache = data + L"\\Local Storage\\leveldb";
assert(CreateDirectoryW(root.c_str(), nullptr));
assert(CreateDirectoryW(installation.c_str(), nullptr));
assert(CreateDirectoryW(data.c_str(), nullptr));
assert(CreateDirectoryW((data + L"\\Local Storage").c_str(), nullptr));
assert(CreateDirectoryW(cache.c_str(), nullptr));
File(data + L"\\Local State");
File(cache + L"\\CURRENT");
File(cache + L"\\LOCK", FILE_ATTRIBUTE_READONLY);
File(cache + L"\\MANIFEST");
// Refused roots: the test root contains the installation; shell roots and malformed paths are rejected.
assert(uninstall_data::Remove(root.c_str(), installation.c_str(), nullptr) == ERROR_ACCESS_DENIED);
assert(uninstall_data::Remove(L"C:\\", installation.c_str(), nullptr) == ERROR_INVALID_NAME);
assert(uninstall_data::Remove(L"relative", installation.c_str(), nullptr) == ERROR_INVALID_NAME);
assert(uninstall_data::Remove(L"C:\\bad*", installation.c_str(), nullptr) == ERROR_INVALID_NAME);
PWSTR profile = nullptr;
assert(SUCCEEDED(SHGetKnownFolderPath(FOLDERID_Profile, 0, nullptr, &profile)));
assert(uninstall_data::Remove(profile, installation.c_str(), nullptr) == ERROR_ACCESS_DENIED);
CoTaskMemFree(profile);
// A protected root overlapping the target in either direction, or an unusable one, stops removal.
assert(uninstall_data::Remove(cache.c_str(), installation.c_str(), data.c_str()) == ERROR_ACCESS_DENIED);
assert(uninstall_data::Remove(data.c_str(), installation.c_str(), cache.c_str()) == ERROR_ACCESS_DENIED);
assert(uninstall_data::Remove(cache.c_str(), installation.c_str(), L"relative\\home") == ERROR_INVALID_NAME);
assert(Exists(cache + L"\\CURRENT"));
// A pinned directory cannot be renamed or replaced while the helper holds it.
{
uninstall_data::Handles pinned;
assert(uninstall_data::Pin(cache, pinned) == ERROR_SUCCESS);
assert(!MoveFileExW(cache.c_str(), (data + L"\\renamed").c_str(), 0));
assert(GetLastError() == ERROR_SHARING_VIOLATION || GetLastError() == ERROR_ACCESS_DENIED);
}
assert(Exists(cache));
// A locked file returns its error but every sibling, including a read-only one, is still removed.
HANDLE held = CreateFileW((cache + L"\\CURRENT").c_str(), GENERIC_READ, FILE_SHARE_READ,
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
assert(held != INVALID_HANDLE_VALUE);
assert(uninstall_data::Remove(cache.c_str(), installation.c_str(), nullptr) == ERROR_SHARING_VIOLATION);
assert(Exists(cache + L"\\CURRENT"));
assert(!Exists(cache + L"\\LOCK"));
assert(!Exists(cache + L"\\MANIFEST"));
CloseHandle(held);
assert(uninstall_data::Remove(cache.c_str(), installation.c_str(), nullptr) == ERROR_SUCCESS);
assert(Exists(data + L"\\Local State"));
assert(uninstall_data::Remove(cache.c_str(), installation.c_str(), nullptr) == ERROR_SUCCESS);
// Empty ordinary parents below the stop directory are removed; the stop directory and populated parents stay.
const std::wstring scope = root + L"\\@scope";
const std::wstring package = scope + L"\\package";
assert(CreateDirectoryW(scope.c_str(), nullptr));
assert(CreateDirectoryW(package.c_str(), nullptr));
assert(uninstall_data::RemoveEmptyParents(package.c_str(), root.c_str()) == ERROR_DIR_NOT_EMPTY);
assert(Exists(scope));
assert(RemoveDirectoryW(package.c_str()));
assert(uninstall_data::RemoveEmptyParents(root.c_str(), root.c_str()) == ERROR_ACCESS_DENIED);
assert(uninstall_data::RemoveEmptyParents(package.c_str(), root.c_str()) == ERROR_SUCCESS);
assert(!Exists(scope));
assert(Exists(root));
// A protected root elsewhere does not block removal.
assert(uninstall_data::Remove(data.c_str(), installation.c_str(), (root + L"\\elsewhere").c_str()) == ERROR_SUCCESS);
assert(!Exists(data));
assert(RemoveDirectoryW(installation.c_str()));
assert(RemoveDirectoryW(root.c_str()));
}
@@ -0,0 +1,102 @@
<# Production uninstaller checks with isolated application identity and seeded data. #>
[CmdletBinding()]
param([Parameter(Mandatory)][string]$Installer, [Parameter(Mandatory)][string]$ProductName,
[Parameter(Mandatory)][string]$RegistryKey, [Parameter(Mandatory)][string]$OutputDirectory,
[Parameter(Mandatory)][string]$Language, [Parameter(Mandatory)][string]$PackageName)
$ErrorActionPreference = 'Stop'
$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
. (Join-Path $PSScriptRoot 'windows-installer-ui.ps1')
[InstallerCapture]::Initialize()
$expected = Get-Content (Join-Path $PSScriptRoot 'expected/windows-uninstall.json') -Raw | ConvertFrom-Json
$roaming = [Environment]::GetFolderPath('ApplicationData')
$local = [Environment]::GetFolderPath('LocalApplicationData')
$installPath = Join-Path $OutputDirectory 'Uninstall App'
$homePath = Join-Path $OutputDirectory 'Harness Home'
$externalPath = Join-Path $OutputDirectory 'External Project'
$productDataPath = Join-Path $roaming $ProductName
# Electron nests user data under the package scope; electron-builder strips the separator from the updater cache name.
$scopePath = Join-Path $roaming $PackageName.Split('/')[0]
$userDataPath = Join-Path $roaming ($PackageName -replace '/', '\')
$cachePath = Join-Path $local (($PackageName -replace '[/\\?<>:*|"]', '').ToLowerInvariant() + '-updater')
$uninstaller = Join-Path $OutputDirectory 'uninstall-copy.exe'
$process = $null
function Wait-Text([string]$Text) {
$timer = [Diagnostics.Stopwatch]::StartNew()
do {
$control = [InstallerCapture]::FindText($process.Id, $Text)
if ($control -ne [IntPtr]::Zero) { return $control }
if ($process.HasExited) { throw "Uninstaller exited: $($process.ExitCode)" }
Start-Sleep -Milliseconds 25
} while ($timer.Elapsed.TotalSeconds -lt 20)
throw "Missing '$Text': $([InstallerCapture]::VisibleText($process.Id))"
}
function Wait-Exit {
if (-not $process.WaitForExit(30000)) { $process.Kill(); $process.WaitForExit(); throw 'Uninstaller timed out' }
if ($process.ExitCode -ne 0) { throw "Uninstall failed: $($process.ExitCode)" }
$process.Dispose()
$script:process = $null
# The NSIS uninstaller hands its section to a temporary copy; the launched process exits before removal completes.
$timer = [Diagnostics.Stopwatch]::StartNew()
while (Test-Path -LiteralPath $installPath) {
if ($timer.Elapsed.TotalSeconds -gt 20) { throw 'Application directory remained' }
Start-Sleep -Milliseconds 100
}
}
try {
foreach ($mode in $expected.cases) {
$setup = Start-Process -FilePath $Installer -ArgumentList ('/S /D=' + $installPath) -PassThru -WindowStyle Hidden
try {
if (-not $setup.WaitForExit(30000)) { $setup.Kill(); $setup.WaitForExit(); throw 'Setup timed out' }
if ($setup.ExitCode -ne 0) { throw "Setup failed: $($setup.ExitCode)" }
} finally { $setup.Dispose() }
$entry = Get-ItemProperty ('HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\' + $RegistryKey)
if ($entry.InstallLocation.TrimEnd('\') -ne $installPath) { throw 'Uninstall entry lacks InstallLocation' }
foreach ($path in @($homePath, $productDataPath, $userDataPath, $cachePath, $externalPath)) { New-Item -ItemType Directory -Force -Path $path | Out-Null }
[IO.File]::WriteAllText((Join-Path $externalPath 'keep.txt'), 'outside data root')
if (-not (Test-Path -LiteralPath (Join-Path $userDataPath 'linked-project'))) {
New-Item -ItemType Junction -Path (Join-Path $userDataPath 'linked-project') -Target $externalPath | Out-Null
}
[IO.File]::WriteAllText((Join-Path $homePath 'session.txt'), 'keep session')
[IO.File]::WriteAllText((Join-Path $productDataPath 'state.txt'), 'product data')
[IO.File]::WriteAllText((Join-Path $userDataPath 'Local State'), 'chromium profile')
[IO.File]::WriteAllText((Join-Path $cachePath 'download.txt'), 'update')
Copy-Item -LiteralPath (Join-Path $installPath ('Uninstall ' + $ProductName + '.exe')) -Destination $uninstaller -Force
$arguments = '_?=' + $installPath
if ($mode -in @('silent', 'home-inside')) { $arguments = '/S ' + $arguments }
# electron-builder's installUtil.nsh runs an older uninstaller with exactly these flags when replacing it.
if ($mode -eq 'upgrade') { $arguments = '/S /KEEP_APP_DATA --updated /currentuser ' + $arguments }
if ($mode -eq 'replaced') { $arguments = '/S /KEEP_APP_DATA /currentuser ' + $arguments }
if ($mode -eq 'home-inside') { $env:DSH_HOME = Join-Path $userDataPath 'home' } else { Remove-Item Env:DSH_HOME -ErrorAction SilentlyContinue }
$process = Start-Process -FilePath $uninstaller -ArgumentList $arguments -PassThru -WindowStyle ($(if ($mode -eq 'interactive') { 'Normal' } else { 'Hidden' }))
if ($mode -eq 'interactive') {
$welcomeText = if ($Language -eq 'ENGLISH') { 'Welcome' } else { -join [char[]]@(0x6B22, 0x8FCE) }
$welcome = Wait-Text $welcomeText
$window = [InstallerCapture]::TopLevel($welcome)
[void][InstallerCapture]::SaveNative($window, (Join-Path $OutputDirectory 'uninstall-welcome.png'), $false)
[InstallerCapture]::Click([InstallerCapture]::GetDlgItem($window, 1))
$finishedText = if ($Language -eq 'ENGLISH') { 'has been uninstalled' } else { -join [char[]]@(0x5DF2, 0x4ECE, 0x4F60) }
[void](Wait-Text $finishedText)
[InstallerCapture]::Click([InstallerCapture]::GetDlgItem($window, 1))
}
Wait-Exit
$retained = $mode -in @('upgrade', 'replaced')
# A DSH_HOME inside the Electron directory protects that directory (and its scope) but not the other roots.
$userDataRetained = $retained -or $mode -eq 'home-inside'
if ([IO.File]::ReadAllText((Join-Path $externalPath 'keep.txt')) -ne 'outside data root') { throw 'Cleanup followed a junction into a project' }
if (-not (Test-Path (Join-Path $homePath 'session.txt'))) { throw "Harness home retention failed: $mode" }
if ((Test-Path (Join-Path $productDataPath 'state.txt')) -ne $retained) { throw "Product data retention failed: $mode" }
if ((Test-Path (Join-Path $userDataPath 'Local State')) -ne $userDataRetained) { throw "Electron user data retention failed: $mode" }
if ((Test-Path -LiteralPath $scopePath) -ne $userDataRetained) { throw "Package scope directory retention failed: $mode" }
if ((Test-Path (Join-Path $cachePath 'download.txt')) -ne $retained) { throw "Update cache retention failed: $mode" }
Write-Output "passed: $mode"
}
} finally {
Remove-Item Env:DSH_HOME -ErrorAction SilentlyContinue
if ($process) { if (-not $process.HasExited) { $process.Kill(); $process.WaitForExit() }; $process.Dispose() }
# Only unique test identities below known application-data parents are removed.
foreach ($pair in @(@($productDataPath, $roaming), @($scopePath, $roaming), @($cachePath, $local))) {
$target = [IO.Path]::GetFullPath($pair[0])
if ([IO.Path]::GetDirectoryName($target) -ne $pair[1] -or -not ([IO.Path]::GetFileName($target).StartsWith('Harness Installer Test ') -or [IO.Path]::GetFileName($target).StartsWith('@harness-installer-test'))) { throw 'Unexpected fixture cleanup path' }
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Recurse -Force }
}
}
+16 -3
View File
@@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
await scaffold?.close()
})
it('shows a running background job in the header without a refresh, then flips it to cancelled on settlement', async () => {
it('shows a running background job in the header without a refresh, then kills it from the two-press stop control', async () => {
let phase = 'running'
onTestFailed(() => saveFailureShot(page, `web-e2e-background-job-${phase}`))
// Polling for zero would pass at t=0 before delivery and prove nothing.
@@ -105,16 +105,29 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
await row.waitFor({ timeout: 10_000 })
await expect.poll(() => row.textContent()).toContain(COMMAND)
const running = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
const running = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd, { runningJobs: 'keep' })
await compareOrRefreshGolden(RUNNING_EXPECTED, running, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
phase = 'settled'
expect(scaffold.ctx.jobs.kill(jobId, agent.id, 'web e2e cancellation')).toBe('requested')
// The whole human path: arm, confirm, job.kill, registry kill, jobs
// frames flipping the row — no registry call from the test.
const stop = page.locator('[data-kill-state]')
await stop.waitFor({ timeout: 10_000 })
await stop.click()
await expect.poll(() => stop.getAttribute('data-kill-state')).toBe('armed')
await stop.click()
// Exact: the running trigger's name ('1 background job running') contains this label.
const idle = page.getByRole('button', { name: '1 background job', exact: true })
await idle.waitFor({ timeout: 20_000 })
// The unclaimed report's reason lands in the settled row's detail.
await expect.poll(
() => page.getByRole('list', { name: 'Background jobs' }).textContent(),
{ timeout: 15_000 },
).toContain('cancelled by the user')
expect(scaffold.ctx.jobs.get(jobId, agent.id).status).toBe('killed')
const settled = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SETTLED_EXPECTED, settled, MODE)
+12 -11
View File
@@ -761,7 +761,9 @@ else process.exit(1);
await openFile('budget.xlsx')
const excel = preview.locator('[data-excel-preview]')
await excel.getByText('季度预算', { exact: true }).waitFor({ state: 'visible' })
const sheetTabs = excel.locator('.luckysheet-sheets-item-name')
await sheetTabs.getByText('季度预算', { exact: true }).waitFor({ state: 'visible' })
expect(await sheetTabs.getByText('隐藏页', { exact: true }).isVisible()).toBe(false)
await successShot(page, 'excel-budget')
expect(await preview.locator('[data-pdf-preview]').count()).toBe(0)
expect(await excel.locator('.fortune-toolbar').count()).toBe(0)
@@ -771,7 +773,7 @@ else process.exit(1);
await expect.poll(() => formulaInput.innerText()).toBe('=C3/B3')
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('80.0%\n')
await excel.getByText('公式与格式', { exact: true }).click()
await sheetTabs.getByText('公式与格式', { exact: true }).click()
await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1')
await sheetOverlay.click({ position: { x: 140, y: 30 } })
await expect.poll(() => formulaInput.innerText()).toBe('46281')
@@ -787,23 +789,23 @@ else process.exit(1);
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('42\n')
await successShot(page, 'excel-cached-formula')
await openFile('meeting.xlsx')
await excel.getByText('会议信息', { exact: true }).waitFor({ state: 'visible' })
await sheetTabs.getByText('会议信息', { exact: true }).waitFor({ state: 'visible' })
expect(await excel.getByText('Read-only preview', { exact: false }).count()).toBe(0)
expect(await excel.getByText('Some formulas have no saved result', { exact: false }).count()).toBe(0)
const formulaWarning = excel.locator('[data-excel-formula-warning]')
await formulaWarning.hover()
await page.getByText('This workbook contains formulas. Displayed results may be missing or inaccurate.', { exact: true }).waitFor()
await excel.getByText('统计看板', { exact: true }).click()
await sheetTabs.getByText('统计看板', { exact: true }).click()
await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1')
await successShot(page, 'excel-meeting')
await openFile('budget.xls')
await excel.getByText('预算', { exact: true }).waitFor({ state: 'visible' })
await sheetTabs.getByText('预算', { exact: true }).waitFor({ state: 'visible' })
await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1')
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('旧版预算')
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('旧版预算')
await excel.getByText('明细', { exact: true }).click()
await sheetTabs.getByText('明细', { exact: true }).click()
await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1')
await successShot(page, 'excel-legacy')
await openFile('literal-html.xlsx')
@@ -812,7 +814,7 @@ else process.exit(1);
['Text', excelHtmlText, excelHtmlText],
['Cached text', '="cached"', excelHtmlText],
] as const) {
await excel.getByText(sheet, { exact: true }).click()
await sheetTabs.getByText(sheet, { exact: true }).click()
await expect.poll(() => formulaInput.textContent()).toBe(formula)
expect(await formulaInput.locator('img').count()).toBe(0)
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
@@ -899,7 +901,7 @@ else process.exit(1);
const emptyOpen = unsupported.locator('[data-textpreview-unsupported] [data-open-path-unpreviewable]')
await emptyOpen.waitFor({ timeout: 15_000 })
const prominent = unsupported.locator('[data-open-target="file"][data-size="large"]')
expect((await prominent.boundingBox())?.height).toBe(40)
expect((await prominent.boundingBox())?.height).toBe(36)
expect((await unsupported.locator('[data-open-path]').boundingBox())?.height).toBe(24)
await successShot(page, 'unsupported')
sections.push([
@@ -975,10 +977,9 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
await Promise.all([
writeFile(join(cwd, 'renamed.docx'), 'This is plain text renamed to docx.'),
writeFile(join(cwd, 'chinese.docx'), realOfficeBytes('docx', 'DSH Missing Preview Font')),
writeFile(join(cwd, 'chinese.xlsx'), realOfficeBytes('xlsx')),
writeFile(join(cwd, 'chinese.pptx'), realOfficeBytes('pptx')),
...(['doc', 'xls', 'ppt'] as const).map(extension => writeFile(join(cwd, `chinese.${extension}`), realOfficeBytes(extension))),
...['doc', 'xls', 'ppt'].map(extension => writeFile(join(cwd, `renamed.${extension}`), 'Plain text is not a binary Office document.')),
...(['doc', 'ppt'] as const).map(extension => writeFile(join(cwd, `chinese.${extension}`), realOfficeBytes(extension))),
...['doc', 'ppt'].map(extension => writeFile(join(cwd, `renamed.${extension}`), 'Plain text is not a binary Office document.')),
])
const convert = vi.spyOn(scaffold.ctx.officeToPdf, 'convert')
try {
@@ -0,0 +1,19 @@
- button "返回插件列表": 插件列表
- button "夹具操作"
- button "卸载 @fixture/live-client": 卸载
- switch "启用 @fixture/live-client" [checked]
- heading "@fixture/live-client" [level=3]
- text: v0.0.1 夹具标签
- paragraph:
- code: "@fixture/live-client"
- heading "包含的组件" [level=4]
- text: 共 1 个 · 1 运行中
- list:
- listitem:
- button "配置 @fixture/live-client": "@fixture/live-client"
- code: fixture-live-client
- text: 运行中
- switch "启用组件 @fixture/live-client" [checked]
- region "夹具区块":
- heading "夹具区块" [level=4]
- paragraph: 来自夹具的区块内容
@@ -1,5 +1,7 @@
- button "返回 @fixture/live-client": "@fixture/live-client"
- button "夹具操作"
- heading "@fixture/live-client" [level=3]
- text: 夹具标签
- paragraph:
- code: fixture-live-client
- paragraph:
@@ -9,3 +11,6 @@
- text: 问候语
- textbox "问候语": 你好
- button "保存"
- region "夹具区块":
- heading "夹具区块" [level=4]
- paragraph: 来自夹具的区块内容
@@ -16,13 +16,37 @@ window.__ModuleLoader__.load({
ctx.effect(() => ctx.locale.register('fixtureLive', {
zh: {
active: '动态插件已启用', configSummary: '示例配置项', configForm: '动态插件配置', configField: '问候语', configSave: '保存',
action: '夹具操作', badge: '夹具标签', section: '夹具区块', sectionBody: '来自夹具的区块内容',
exportSession: '导出会话', copySessionId: '复制会话 ID',
},
en: {
active: 'Live plugin enabled', configSummary: 'An example setting', configForm: 'Live plugin configuration', configField: 'Greeting', configSave: 'Save',
action: 'Fixture action', badge: 'Fixture badge', section: 'Fixture section', sectionBody: 'Section content from the fixture',
exportSession: 'Export session', copySessionId: 'Copy session ID',
},
}))
// Detail contributions for this bundle's own page and its row's page: an
// action at the head, a badge beside the title, and a section below.
// The subject says what the open page is about; another bundle's page
// gets nothing from these entries.
const mine = (subject) => (subject.kind === 'bundle' || subject.kind === 'row') && subject.pkg.name === '@fixture/live-client'
ctx.slots.inject('plugins.detail.actions', () => ctx.slots.register({
name: 'plugins.detail.actions', id: 'fixture-live-client', locale: 'fixtureLive',
}, ({ t, subject }) => mine(subject)
? React.createElement('button', { type: 'button', 'data-live-action': subject.kind }, t('action'))
: null))
ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({
name: 'plugins.detail.badge', id: 'fixture-live-client', locale: 'fixtureLive',
}, ({ t, subject }) => mine(subject)
? React.createElement('span', { 'data-live-badge': subject.kind }, t('badge'))
: null))
ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
name: 'plugins.detail.section', id: 'fixture-live-client', locale: 'fixtureLive',
}, ({ t, subject }) => mine(subject)
? React.createElement('section', { 'data-live-section': subject.kind, 'aria-label': t('section') },
React.createElement('h4', null, t('section')),
React.createElement('p', null, t('sectionBody')))
: null))
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
name: 'shell.overlay', id: 'fixture-live-client', locale: 'fixtureLive',
}, ({ t }) => React.createElement('div', { 'data-live-client': '' }, t('active'))))
Loaded 100 of 464 files, more files were not shown because too many files have changed in this diff. Show more