mirror of
https://github.com/dsh-market/dsh-market.git
synced 2026-09-28 05:03:07 +08:00
main
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4ceda5d2b7 |
release 1.66.3: a release-age exclusion is never widened to a bare name
1.66.2 rewrote `name@a || b` release-age exclusions into bare package names. That exempts every version of the package from the pnpm release-age cooldown, which is wider than what the profile's file declares — and the market pins exact versions precisely so a fresh install cannot silently land on an older release (#594). The review on #733 had settled that the union spelling is a documented pnpm form and that the 80 GiB abort reported there is pnpm's to fix, and the reporter's own follow-up could no longer reproduce it. This release restores merging: several rules for one name become one union of the versions the file already lists, a union that is already the only rule for its name is left alone, and a bare name stays bare. The merge still runs before the command rather than after a failure, which is what keeps #732's every-command-fails state from arising at all. |
||
|
|
f81e7df3aa |
revert(install): never widen a release-age exclusion to a bare package name (#733)
1.66.2 replaced the #732 duplicate merge with a rewrite of `name@a || b` into a BARE package name. That was wrong, and the review on #733 had already said so before it shipped: - `name@a || b` is a documented pnpm spelling (the validator's "Use exact versions only" is about ranges and name patterns, not exact-version unions); - a bare name exempts EVERY version of that package from the release-age cooldown, which is wider than what the file declares. The market pins exact versions precisely so a fresh install cannot silently land on an older release (#594), so widening the exclusion list works against that; - the 80 GiB abort it was meant to avoid could not be reproduced by review or by the reporter's own follow-up, and pnpm's maintainers took it upstream (pnpm/pnpm#15867). Back to merging: several rules for one name become one union of the versions the file already lists, a union that is already the only rule for its name is left exactly as written, and a bare name stays bare. Nothing is widened, and nothing pnpm wrote is rewritten into a form it did not ask for. Kept from 1.66.2: the merge runs BEFORE the command instead of after a failure, and covers every verb that reads the key (add, remove, install, update). That ordering is what keeps the #732 failure from happening at all, and it costs one small file read. |
||
|
|
1c563e52db |
fix(desktop): an explicit profile: desktop cannot fall back to the forbidden CLI (#744)
The dsh CLI refuses a profile named `desktop` outright, so the branch the market takes matters more than which name it uses. An operator who names that profile explicitly in cordis.yml — which is the workaround a host that hides `profileContext` leaves them (#744) — was routed to the CLI branch anyway, where every install, update and uninstall fails for certain. The official branch now also covers a configured `desktop`, and the configuration still wins for the NAME; only the branch is no longer chosen by a rule that is guaranteed to fail. The launcher's directory rides along only when the launcher is the one that named this profile, so a configured profile never inherits another one's path — the existing #639 rule is unchanged, and its test still passes. `createOfficialDesktopRuntime`'s third parameter becomes optional: the manager bridge takes the profile by NAME and never used it, and with a configured profile there is no launcher directory to pass. Verified by mutation: restoring the old condition turns the new case red (`officialFactoryArgs` empty — the CLI branch). |
||
|
|
6fe861567d |
fix(pnpm): classify a patch pnpm refuses to use, and name it (#740)
`patchedDependencies` keys on `pkg@exactVersion`. When the installed version moves past that key, pnpm 12 answers the stale patch with ERR_PNPM_UNUSED_PATCH — a HARD error that writes NOTHING at all. So an update of that plugin is refused in full: the version stays where it was, the market's log holds only exit=1, and the user reads "the update did not apply" for what is really a patch that no longer matches. `ERR_PNPM_PATCH_FAILED` was already classified (it installs the package UNPATCHED, and the damage shows at the next boot). This is the other half of the same story and the more confusing one, because there is no unpatched install to discover later and no second error to follow. The new branch names the entries pnpm listed and says where to fix them: retarget the patch and its pnpm.patchedDependencies key to the new version, or drop both when the release already carries the fix. It stays the user's patch — the market does not guess a retarget — so nothing is repaired automatically. The extraction the report proposed matched to the end of the line, which in the ndjson stream (where the same sentence arrives inside a JSON string) captured `"}}` as part of the package name; the test for that shape caught it, and the match now stops at the quote. |
||
|
|
9bc6120ba5 |
fix(check): a host peer's version is not read from another installation (#726)
A Desktop host that cannot locate its own installation falls back to the profile anchor's parent search paths, and `<profiles>/node_modules` is shared by EVERY profile. On a machine that also runs a global npm CLI — the reporter's, with the Desktop app at 0.1.7-rc.2 and the CLI at 0.1.5-rc.2 — that tree is the other installation's closure, so the profile-visibility fallback read 0.1.5-rc.2 as "the host" and the market reported a healthy install as "introduced host-compatibility risks — @deepseek-ai/dsh@>=0.1.7-rc.2 vs 0.1.5-rc.2", with a one-click rollback of a working plugin. For a host-plane peer (`@deepseek-ai/*`) the version now comes from the plugin itself (a nested copy), then the profile's own node_modules, then the located installation — never from the shared workspace root. With none of those the version is UNKNOWN rather than wrong, which is the rule #676 already settled for bundles; the market's pre-flight compatibility check is unaffected, since it asks the host detector (`dshHostInfo`) rather than this report. Verified by mutation: reverting the guard makes both new tests fail, and the failure IS the report — 0.1.5-rc.2 leaks back in as the resolved peer. Not covered: the other route to a wrong version — an auto-detected install dir that itself resolves to the global CLI (`findDshInstallDir()` when the host cannot be located). The reply asks for the diagnostics datum that separates the two: the path the version was read from. |
||
|
|
6450b0a567 |
release 1.66.2: a release-age exclusion is never written as a version union (#733)
A machine-killer, and one this market had started producing itself. pnpm writes `minimumReleaseAgeExclude` in forms it then cannot read back. One is a shadowed duplicate rule (#732). The other, reported separately as #733 on pnpm 12.4.1, is `name@v1 || v2 || …` — a union its own validator rejects, and one that aborts the process on a single 80 GiB allocation when the resolver evaluates it. The reporter watched the machine lock up for about 200 seconds, the page file grow to 22 GB, and pnpm exit on 0xC0000409 with no error text of its own. Their A/B: the list as written aborts in 209s, deleting the block takes 3.2s, and writing bare package names instead takes 3.9s. 1.66.1's repair for #732 merged the two rules into a union, so it produced the second shape. It now writes ONE BARE NAME per affected package — the spelling that cannot be shadowed, carries no union, and stops pnpm's auto-collect — and only for entries pnpm already wrote in one of those two broken forms. The rewrite runs before the pnpm invocation rather than after a failure, because the abort leaves nothing to classify, and covers every verb that resolves the dependency graph. |
||
|
|
f063c3b8cd |
fix(install): a release-age exclusion is never written as a version union (#732, #733)
pnpm writes this key in forms it then cannot read back, and 1.66.1's repair adopted one of them. Two reports, one shape: - #732: pnpm 11.7.0 appends a second rule for a package that already has one and honours only the FIRST per name, so its own entry is dead and every later command in that profile fails lockfile verification. - #733: pnpm 12.4.1 folds the versions it approves into `name@v1 || v2 || …`, a form its own validator rejects ("Use exact versions only"), and evaluating one aborts the process on a SINGLE 80 GiB ALLOCATION — the machine locks up for minutes and pnpm prints no error at all. Measured there by A/B: the list as written aborts in 209s, deleting the block takes 3.2s, and replacing it with bare package names takes 3.9s. 1.66.1 merged the duplicates into a version union, which is the second shape: on pnpm 12.4.1 that turned a benign pair (first rule exact and live, second rule dead) into one live union for the resolver to evaluate. So the repair no longer produces it. Each package whose entry is a union, or has several rules, is now written as ONE BARE NAME: it cannot be shadowed, it stops pnpm's auto-collect for that package outright, and it carries no union. It is a wider statement than a version list — that package stops being age-gated — so it is spent only on entries already in one of those two broken forms, never on a package the file lists as a single exact version, and a pure duplicate of one exact version collapses to that one line, which changes no policy at all. The rewrite also moved BEFORE the run instead of after a failure. The union shape aborts with no output to classify, so a repair waiting on a failure would never fire; and it now covers every verb that resolves the graph (add, remove, install, update), not just add/remove. Self-healing for the profiles already carrying unions: the next plugin operation rewrites them before pnpm reaches the key. |
||
|
|
d8b53972e6 |
fix(install): repair the shadowed release-age rules on the declined-bypass path too (#732)
Follow-up to the fix released in 1.66.1, from the third independent reproduction on the issue. - The duplicate-rule merge now runs for every release-age violation on an add/remove, not only where the caller allowed the one-shot bypass. The merge restores what the file already declares, which is not the same act as relaxing the profile's age policy, so a caller that declined the bypass (#594) still gets the file repaired — and the command itself is re-run unchanged, with no option involved. A rule the user made order-independent (a bare package name) is kept bare: the merge never narrows it back to the versions it happened to list. - The rollback-unavailable message for an inconsistent npm capture now names all three versions — what was on disk, what pnpm-lock.yaml records, and what a rollback would have reinstalled. "pnpm-lock.yaml does not match it" left the user nothing to act on; the reproducer's own way out was to align package.json with the installed build, and the message now says so. |
||
|
|
297bb009fa |
release 1.66.1: the desktop profile's plugin operations work again (#732)
The official desktop bridge takes exactly `add <target>` or `remove <target>`, and every recovery step this market added to a command — the #39 one-shot release-age bypass, --force on the update rollback, --config.* and --no-frozen-lockfile generally — was therefore refused with exit 127. The panel reported that refusal as "this desktop operation is not supported" for operations that are supported, and the rollback's failure read as "restoration of the previous build could not be verified" while node_modules kept the bad build. Behind it, pnpm 11.7.0 appends a second `minimumReleaseAgeExclude` rule for a package that already has one and then honours only the first, so its own entry is shadowed and every later command in that profile fails lockfile verification. The market's self-update plants that pair, which is why the reporter lost install, update and uninstall on that profile for two days. Both are fixed in the commit before this one: the runtime now declares whether it accepts the market's options and the recovery steps are left out where it does not, the rollback sends the bare exact target its manager pipeline materializes, and a release-age violation first merges the shadowed duplicate rules — a repair of the file, so it needs no option and works on that host too. The bridge names the option and the way out instead of calling the operation unsupported. |
||
|
|
78de8d3d94 |
fix(desktop): never hand a market option to a host that refuses options (#732)
The official Desktop bridge takes exactly `add <target>` or `remove <target>` and answers any other argv with exit 127 — which the panel reported as "this desktop operation is not supported" for operations that ARE supported, dressed in one of the market's own options. Two consequences on that host: every recovery step that decorates a command with an option was impossible, and the update rollback's `add --force <override> <target>` failed as well, so a failed update reported "restoration of the previous build could not be verified" while node_modules kept the build that failed. - `PluginCommandRuntime.acceptsMarketPnpmFlags` declares the capability; the official desktop runtime sets it false, and the route layer reads it once. - `withHoistRecovery` skips its four option-carrying recoveries there, and says so, because three of their classifier messages state that the market retried — a retry that could not happen. - The rollback paths send what that host does accept: the bare exact target, which its own manager pipeline materializes. `install` is not expressible there at all, so the manifest rematerialization uses the restored pin's exact version, and says which package it could not rebuild when the pin is a range. - The bridge's refusal now names the option and the way out. Root cause A of the same report is fixed too. pnpm 11.7.0 APPENDS a second `minimumReleaseAgeExclude` rule for a package that already has one, and then honours only the FIRST per name — so its own new entry is shadowed, the young version stays unexcluded, and EVERY later command in that profile fails lockfile verification. The market's own self-update plants exactly that pair, which is why every plugin operation on that profile stopped working. A release-age violation now first merges the same-name rules. That is a repair of the file rather than a bypass of the policy (the union of what the file already declares is unchanged), so it needs no option and works on the host that refuses them. Verified against the report's own measurement: the merged file passes `Lockfile passes supply-chain policies`. |
||
|
|
1f1ed901a6 |
docs(client): the settings tab did not replace the bundle page seat (#722)
The #722 comment claimed the `plugins.bundle.config` registration never fires because no host declares that slot. 0.1.7 does declare it (`ui-plugin-manager`: a bundle's own configuration, keyed by the bundle's package name, rendered on the bundle's page), and the market renders there on its bundle's page in the Plugins section. What actually went silent on that line was the settings-list seat, when `settingsScope` was renamed. Both seats are deliberate and stay. |
||
|
|
63a6467c70 |
ci: fail loudly when the site falls behind the catalog
The 2026-09-09 -> 2026-09-25 freeze was invisible. Twelve consecutive scheduled build-site runs failed and dshmarket.com kept serving the 2026-09-08 catalog: 3,363 plugin pages live against 4,347 in the catalog, so 1,082 plugins answered 404 — and nothing outside the repository changed to say so. This adds a daily check that reads the deployed site and compares its plugin-page count against the catalog the build consumes, failing when the site is more than 300 entries behind. Measured 2026-09-03..09-25 the catalog grew 62.5 entries/day, 205 on its busiest day, so a burst rides through while a freeze is caught inside its first week rather than its third. It measures the deployed artifact, not CI's own report, so it also covers a build that never runs at all: GitHub disables a scheduled workflow after 60 days of repository inactivity, which no failure log can show. |
||
|
|
1b1fff56b6 |
release 1.66.0: the market's seat in the Plugins section, and four reported defects
Three of these are fixes for defects that reached users on 0.1.7, and one is the entry point the Plugins section was missing. - #719 — a restart could deadlock: the helper declared the replacement dead at 28s, the recovery surface took the port, and the replacement — which binds at ~42-45s on a source-run host — died on EADDRINUSE. Every later start hit the surface still holding the port. The helper now watches for 45s (a replacement that exits still ends the wait early), carries the pid through the handoff, and the surface waits for that process to be gone before binding, because the port alone cannot tell "dead" from "still starting". - #722 — the settings card vanished on 0.1.7. `settingsScope` and `settings.plugin.item` were removed by that release, and `plugins.bundle.config` — the seat the code assumed — is not in its slot contract at all. The card now follows `settings.plugins.tab`, which is also the market's entry point in the Plugins section. - #731 — the diagnostics badges were white on white in dark mode: `--dsw-alias-brand-primary` is an ink token (near-white in dark), and the market used it as a background with a hardcoded #fff. Four more surfaces had the same shape, including an avatar letter that was invisible in LIGHT mode. A stylesheet guard now refuses a themed background with a literal text colour. - #723 — a tag-pinned git install reported an update forever: the smart-HTTP path resolved the annotated tag's OBJECT instead of the peeled `^{}` commit the lockfile records. #597 fixed the GitHub half; this is the other half. Carried from the unreleased work: the scan result moved off the cards into the install dialog behind a disclosure (only the install-time script is shown up front, because 73% of all red lines describe what plugins normally do), and 120 strings were rewritten against the ux-writing standards in both languages. 77 files, 1842 tests. AGENTS.md records the product design principles these decisions were made against. |
||
|
|
f2971fae13 |
feat(client): the market gets its seat in the host's Plugins section (#722)
0.1.7 removed the `settingsScope` service and the `settings.plugin.item` slot that hung off it, and the replacement this code assumed — `plugins.bundle.config` — is not in that release's slot contract at all: it appears nowhere in the packed 0.1.7 client, while `settings.plugins.tab` is declared in its types, its docs and its runtime registrations. A registration into a slot nobody declares is silent by construction, so the market's card simply disappeared on that line, which is what the report said. The host's own contract describes `settings.plugins.tab` as "one page inside the Plugins settings section … the section owner renders localized entry labels as tabs and mounts each contribution inside its corresponding tab panel" — the official seat for what the market wants, and the entry point that was asked for. The card follows the slot, as it does everywhere else: `settings.plugin.item` on the older line, `plugins.bundle.config` where a host declares it, and now the tab. Verified on a real 0.1.7-rc.2 host: the Plugins section renders `Plugin list` and `Plugin Market` as sibling tabs, and the market's card is on the second. |
||
|
|
466732c54c |
fix(recovery): do not take the port from a replacement that is still starting (#719)
The restart helper declared the replacement dead 20s + 8s settle after spawning it and handed the port to the recovery surface. A source-run host with a large plugin tree reaches `webserver.listen` at ~42-45s, so on that timeline the surface bound the port first and the replacement died on EADDRINUSE — and the process just killed was the real host, so every later start hit the recovery surface still holding the port. A forgotten-page timeout meant it never came back on its own. Both sides of the handoff lost the race by design, so both are fixed: - the helper watches for 45s + settle, matching the budget src/recovery.ts allows a boot. A replacement that EXITS still ends the wait early, which is the common failure, so a genuinely broken composition is not made slower; - the helper passes the replacement's pid through the handoff, and the recovery surface waits for that process to be gone before it binds — the port alone cannot tell "dead" from "still starting", and that is exactly what it was being asked to decide. The wait is bounded by the same 30s a released port gets, and it says which of the two it saw. Verified by running the real helper and the real surface against a live stand-in for a slow boot: with the process alive the port stays unanswered (the assertion goes red without the wait), and the surface comes up once it exits. The two helper tests that shrank the old literal to keep the suite fast now shrink the new one. |
||
|
|
1887bd0015 |
fix(updates): read the peeled commit of an annotated tag (#723)
A tag-pinned git install advertises twice: the tag OBJECT on `refs/tags/<t>`
and the COMMIT it points at on `refs/tags/<t>^{}`. `resolveGitRemoteHead` ran
the same regex over `heads` then `tags` and stopped at the first hit, so a tag
resolved to the tag object — which a pnpm lockfile never records. `current` was
the commit, `latest` was the tag object, the two could not be equal, and the row
claimed an update forever; applying it reinstalled the same commit and reported
"version did not change". #597 fixed exactly this on the GitHub path
(`resolveHeadCommit`); the smart-HTTP path self-hosted git uses kept the bug.
The tag namespace now checks `^{}` first and falls back to the direct ref, so
lightweight tags — which advertise no peeled line — resolve as before.
Two cases added, both in the gitea harness the #525 work built: the annotated
tag resolves to the commit and reports no update, and the lightweight tag still
resolves. Removing the peeled branch turns the first red and leaves the second
green, which is the pair the fix has to satisfy.
|
||
|
|
451fbd3bc3 |
fix(client): the diagnostics badges were painted with an ink token (#731)
`--dsw-alias-brand-primary` is an INK colour: the host resolves it to #0f1115 in the light theme and to #f9fafb in the dark one, and uses it for text and strokes while the surface underneath is a different token. The market had it as a badge *background* with a hardcoded `#fff` label, so in dark mode on 0.1.7 the fill and the text were both #f9fafb — white on white. The bundle "official" badge and every row of the override list rendered as empty white pills, which is what the report showed; the numbers were still there, invisible. Verified on a real 0.1.7-rc.2 host, both themes, computed styles: - before: `ovByTag` / `diagBadgeOfficial` bg rgb(249,250,251), colour rgb(255,255,255) — identical channels, nothing to read - after: dark bg rgb(249,250,251) colour rgb(15,17,21); light bg rgb(15,17,21) colour rgb(255,255,255) The host's own filled pair is `button-primary-fill` + `label-primary-foreground` (its primary Button and Pill use exactly that) — both with the old pair as the fallback, so hosts that predate those tokens are unchanged. The same sweep found four more surfaces hardcoding white on a themed fill, and the avatar letter that was white on `bg-layer-2` — which is white in the light theme. `tests/style-tokens.spec.ts` keeps it from coming back: no rule may set a themed background and a literal text colour together. Deliberately not checked: an ink token *as* a background — that is the correct fill for a progress bar and for the 9% tints the market already uses. |
||
|
|
1e80b3a929 |
feat(client): the scan result moves into the detail dialog, and 120 strings get read as copy
Two changes to what a reader of the market actually sees.
**The capability disclosure leaves the cards.** It sat on both card renderers
as a row of chips plus amber red lines. It moved into the install dialog: the
card now says nothing about the scan, the dialog leads with the one line that
needs a decision before the install runs — a script that executes AT install
time, where there is no afterwards to inspect — and everything else is behind
a disclosure the reader opens on purpose, next to the blind-spot sentence and
the scan date.
The reason is the amber. 354 of the catalog's 483 red lines are the same one
("reads credentials/secrets AND has network access"), which is what a plugin
that calls a model normally does; flagging that as urgent on every card is how
a warning gets trained away, and the reader who has learned to skip the amber
line will skip the install-time script too. So urgency is now a rule rather
than a rendering: `redLineIsUrgent` names the two families that fire during
the install, and the other three are stated as facts.
**Every scanner sentence family is now translated.** Two of the five shapes
fell through to English on a Chinese card — the reported "uses literal IP
198.18.0.0 for network access" was one of them — and `tampers with a core
bundle` had no copy at all. They are matched by their stable prefix so the
parenthesised detail can change without falling back.
**The copy sweep.** 643 keys, both languages, reviewed against the ux-writing
standards: jargon that had reached the surface (bundle / profile / 凭据 /
解析依赖 / 幂等 / 实例 / disable-carrier), error messages with no next step,
the same concept named two ways, and Chinese and English that had drifted
apart. 120 strings changed; "未检出 / 未被判定" became "没有扫描到。这不等于
安全。" so the absence of a finding stops reading as a finding of absence.
Tests: 76 files, 1837 pass. Two assertions that quoted the copy verbatim now
compare against the copy's own template, so a wording change fails only a
wiring change, and the #485 case moved off a prefix match that the rewritten
hint had made ambiguous.
|
||
|
|
092c3ace39 |
revert: keep the download mark focusable — the reviewer was wrong
|
||
|
|
b43798f5f0 |
fix(client): the download disclosure is a byline mark, not a tab stop
#721 made the rolling-window mark focusable (`tabIndex={0}`) so the tooltip is reachable without a mouse. It is the only focusable byline mark in the file — `↓`, `★`, the catalog version and the capability title all sit in plain spans inside the host's Tooltip — and it would add one tab stop per card plus a paragraph of screen-reader output per stop, on a grid that renders dozens. Nothing is lost by dropping it: the same text is rendered as body copy in the detail dialog, which is where a keyboard user is heading anyway, and the `aria-label` and the visible `· ↓ 1.2k / 30d` both stay. Everything else in #721 stands as reviewed: the three source fields exist for all 2153 entries that report a count (no entry has a count without them), the committed client artifact matches a fresh build, and the new assertions are load-bearing — relaxing `dateOnly` to `typeof value === 'string'`, dropping the integer guard, fabricating a window from missing dates, and dropping the reversed-range check each turn their own cases red. |
||
|
|
7b67f9679d |
release 1.65.4: 1.65.3 claimed to fix #729 and did not
The fix was right about everything except WHEN the market asks. On a web host `HostConnectionService` is constructed behind `await BrowserAuth.create(...)` (`dsh-client-connection` 0.1.5+; its `apply` is async and injects `credentials`), while the market mounts on `webServer` + `loader`, which are ready first. So the one read `useTrustedHosts` took at mount saw no service, and `Array.isArray(connection?.trustedHosts) ? ... : []` turned "not asked yet" into "none declared" — an empty list handed to the fence for the life of the process. Reached by a name: every mutating route answered 403 while reads and loopback kept working, which is exactly the report #729 made against 1.65.2. In other words 1.65.3 fixed a fence nobody was failing to feed. Verified this time against a real 0.1.7-rc.2 host, not the unit lane: - 1.65.3, `--trusted-host dsh.example.org`, POST /dsh-market/restore with a matching Host/Origin -> 403 `untrusted origin`; loopback -> 400 (reached). - after #730: declared name -> 400 (reached), loopback -> 400, undeclared name -> 403, `sec-fetch-site: cross-site` -> 403. The one-time warning never fired, so the service was there to be read. 1821 tests, 75 files, typecheck clean. Thanks to the report for taking the time to test the shipped artifact rather than the branch. |
||
|
|
2b8dbc8f10 |
fix(index): read the host's authorities per request, not once at mount (#729)
The read itself was right; it landed too early. `HostConnectionService` is constructed behind `await BrowserAuth.create(...)`, so the `connection` service does not exist yet when the market mounts on `inject(['webServer', 'loader'])` -- both of those are ready first. The one read the market took therefore saw `undefined`, and `Array.isArray(connection?.trustedHosts) ? … : []` turned "not asked yet" into "none declared": that empty list was handed to setTrustedHostsSource for the life of the process. So 2b67f66's own subject is still true on a web host -- a deployment reached by a name answers 403 `untrusted origin` to every mutation while reads and loopback keep working, which is #729. Measured on a real host (market 1.65.3): the mount-time read sees `undefined`, and the same ctx read while handling a request sees `['<name>']`. The fix is the idiom `agentsLookupOf` already uses two functions up -- resolve at request time -- plus a one-time warn when the service is absent, because the silent fallback is what made "we could not ask" and "nothing is declared" the same fence and hid this for a release. `useTrustedHosts` is exported for the spec that covers the wiring; the fence it feeds stays covered through `sameOrigin` in tests/http.spec.ts, and the flows case sets the source directly, so the two of them together could not see when the market reads it. Verified: 1821 unit tests over 75 files green, including the new tests/trusted-hosts.spec.ts -- red against a mount-time read, green here; typecheck (src, client, tests) clean; and end to end on a real DSH web host, where a declared name reaches the handler (400 from the route's own body validation), an undeclared name is still refused (403), loopback still works, and a real mutation from the declared name answers {"ok":true,...}. |
||
|
|
d832e434b5 |
release 1.65.3: a deployment reached by name stays writable
The fence fix from #729, on the released line. 1.65.2 was tagged from the wrong branch in my working copy — it carries the staged-toggle work that is still on `wip/toggle-transaction` (verified at 4 of its 5 real-host e2e scenarios) along with this fix. This release is main plus the fix and nothing else, so `latest` names content that has been verified end to end; 1.65.2 should be deprecated or unpublished, which needs an interactive credential and is the maintainer's call. It answers a total functional break: on any deployment reached by a name — reverse proxy, tunnel, LAN hostname — every mutating route answered 403 untrusted origin while every read worked, so it presented as "the install button does nothing". The fence now accepts the authorities the host declares (`--trusted-host` and the LAN literals DSH derives), which is the half DSH's own /api routes already had and the market could not inherit; an undeclared name is still refused. |
||
|
|
2b67f668f6 |
fix(http): accept the authorities the host declares, not loopback only (#729)
Reached by a name — a reverse proxy, a tunnel, a LAN hostname — every mutating
route answered `403 {"error":"untrusted origin"}` while every read kept
working, so it read as "the install button does nothing": install, update,
uninstall, toggle, snapshot restore and preset save all refused, for every
deployment that does not use `http://127.0.0.1:<port>`.
The fence's rule is right and stays — Host is the one header DNS rebinding
cannot forge, so the authority has to be ours. What it lacked is the other
half of "ours": DSH's own /api fence accepts loopback OR an authority the
operator declared (`dsh web --trusted-host <name>`, plus the LAN literals the
CLI derives when bound to 0.0.0.0), and the market's routes are `exact`
registrations on the bare webServer, so that fence never sees them and the
market had to decide for itself.
It now reads the same list back from the host's `connection` service
(`trustedHosts`, public on `HostConnectionService`) once at mount, and:
- mirrors the host's matching rule, because the two fences have to agree: an
entry with an explicit port matches that exact authority, a port-less entry
matches the hostname on any port (the shape the CLI derives for IP-literal
LAN serving, where the port may be OS-assigned);
- refuses an entry that does not survive WHATWG parsing unchanged, so
`dsh.example.org/path` cannot authorize `dsh.example.org` — the rule the
host's own loader enforces loudly for the same reason;
- refuses `sec-fetch-site: cross-site` outright, which the host's fence also
does and this one did not: it covers what the Origin equality cannot, a
cross-site request whose Origin happens to match.
An undeclared name is still refused, which is the security property: the list
only ever contains what an operator wrote into the deployment's configuration.
The restart and download fences deliberately keep the stricter posture they
have (loopback PEER only, no forwarding headers): process control is for the
machine itself, not for whoever can reach the GUI.
Verified: 1851 unit tests, including a new tests/http.spec.ts (8 cases: the
reported repro refused undeclared and accepted declared, exact-port vs
port-less matching, cross-site refusal, malformed entry, restore) and a flows
test driving a real mutating route from a declared name and from an undeclared
one. Three mutations each turn exactly one of them red. npm run check clean.
|
||
|
|
c9705fd9cd |
refactor(client): the host's Checkbox where its string label fits
Two of the market's five checkboxes are a plain box plus a short label (Advanced → automatic backup; the export dialog's "include configuration"), and those now render the host's `Checkbox` — matching the settings page around them. Older hosts keep the market's label+input. The other three deliberately do not, and the reasons are written down where the next person will ask: - the export picker's per-plugin rows carry rich label content (name, spec kind, resolved spec), and `Checkbox` takes a STRING label — routing them through it would either drop that content or print the name twice; - the recovery panel's checkbox has no visible label at all; - the gist mode picker is a pair of RADIOS, and the host ships no radio primitive (0.1.7-rc.2: Checkbox, Switch, SegmentedControl, Tag — no radio). Verified: 1808 unit tests, npm run check clean, and a mutation that ignores the host component turns the new test red. |
||
|
|
f4294b4912 |
refactor(client): one on/off control, the host's Switch when the host has one
The market rendered the same hand-rolled `role="switch"` markup in three places (the installed row, a group member row, the detail view) and none of them had a test: `Switch` is one of the components 0.1.7-rc.2 added, and the host's own plugin list uses it, so the market's rows now take it too and match the list they sit in. Older hosts get the market's switch, unchanged. The group header's switch is deliberately NOT routed through the helper, and now says so: a group's state is three-valued (all on / all off / mixed) and the host's Switch is boolean — adopting it would flatten "mixed" into one of the two, which is the one thing that control must not say. Both halves are asserted: the host path in tests/client/optional-primitives.client.spec.tsx (`Switch` mocked in, checking the installed row renders it with the right label), the fallback in the main spec, which runs against a host without `Switch` — the first test that control has ever had either way. Also found while wiring this: `SegmentedControl` (the List/Groups switch) is NOT adopted. Its contract is a real tablist whose every tab carries `aria-controls="<id>-<value>-panel"`, so the panels have to be rendered — and the installed view's JSX has no container to hang them on, which makes the change structural for a sub-switch that already looks deliberate. Reported rather than forced. Verified: 1807 unit tests, npm run check clean. |
||
|
|
3106fa8565 |
fix(client): the same disclosure row on both cards, and a resolver that survives strict shims
Found by looking at the running market rather than at the source: the Themes gallery draws a different card from Discover's masonry, and the capability row had only been added to one of them — so the same plugin showed "What it touches writes files / reads files / network" on Discover and nothing at all on Themes. A fact on one card and not the other reads as a difference between the PLUGINS, which is the one thing this feature must not imply. The row is now one function used by both renderers, with a test scoped to each card (the earlier version of that test passed with the row removed: both tabs stay mounted, so an unscoped query found the Discover card's chips and never looked at the theme card at all — caught by mutating the change and watching it stay green). `optionalComponent` also survives a shim that THROWS on an unknown export rather than answering undefined — vitest's module mock does that, and a host's could. "This host does not have the component" and "asking threw" are the same answer, and both call for the fallback. Verified: 1805 unit tests, npm run check clean, and the theme cards confirmed by screenshot against a real 0.1.7-rc.1 host. |
||
|
|
a5d840b663 |
refactor(client): use the host's Tag for disclosure chips, and keep the fallback honest
A review pass over the market's UI against the host's ui-primitives: what the running host can offer (0.1.0-rc.7 exports 25 groups, 0.1.7-rc.2 exports 51) versus what this package hand-rolls. `src/client/optional-primitives.ts` is the new seam, and it is deliberately the same shape as the icon aliases: resolve the host's component when it is there, fall back to the market's own markup when it is not, and make the fallback mandatory by running the tests against an old host — a call site that forgets it fails loudly in this suite instead of silently in production. It is NOT `REQUIRED_PRIMITIVES` (src/client/index.ts): that one decides whether the market can render at all; this one decides how a chip looks. First use: the capability chips. On a host that ships `Tag` they are the host's own tags (`tone="outline"` for detected capabilities, `tone="quiet"` for 未检出/未扫描), so they match the settings page they sit in; on an older host they stay the market's spans. Both halves are asserted — the fallback in the main spec (which runs against 0.1.0-rc.7), the host path in tests/client/optional-primitives.client.spec.tsx with `Tag` mocked in. Also: `.specTagGit` / `.specTagFile` were the last two colours not taken from the host palette; they now read `--dsw-alias-state-business-primary` and `--dsw-alias-state-warn-primary` with the previous values as fallbacks. Reported, not done in this pass: the installed list's list/groups switch is a hand-rolled segmented control (`SegmentedControl` exists on 0.1.7 and needs its `<id>-<value>-panel` panels rendered to be honest about `aria-controls`), the enable/disable rows could be `Switch`, the settings inputs `Input`/`SettingsForm`, the lightbox `ImageLightbox`, and the card avatars `PluginArtwork*`. Verified: 1804 unit tests (348 of them client), npm run check clean. |
||
|
|
0370c55841 |
fix(toggle): a strict entry enable that restores what it touched (#582)
The bundle-layer toggle drove loader entries best-effort: a rejected `entry.update` was a log line, and the toggle carried on and reported the state it had asked for. Two things follow from that, both of them user-visible — a successful-looking enable over a fiber that is not there, and a partly-applied enable whose earlier entries stay flipped while the later one failed. `setEntryDisabled(name, flag, requireLive)` is the strict mode, ported from @bulingbuling688's branch together with its four scenarios: - every entry this call touches is remembered, and a failure restores them in reverse to the disabled flag AND the live state they had — the previous live state is part of the record, because an entry that had been enabled before the failure must not come back disabled; - an update that fulfils without producing a live fiber is a failure, not a success: `did not become live`; - a rollback that cannot complete says so (`entry restoration failed: …`) rather than reporting an error from the original operation alone; - a hung update is bounded at 10s (`waitForLifecycle`, which also refuses a second mutation on a loader object whose first one is still pending), and a later enable of an entry whose last update never settled is refused until a disable has been seen through. The legacy callers keep the best-effort semantics they were written against — the boot replay and the theme paths must not start rejecting — so strictness is opt-in, and the hung-update bound keeps them from waiting forever. Verified: four scenarios (ported from #582) assert each behaviour, and three mutations of the port turn exactly the matching one red (rollback loop removed, live-state verification removed, rollback failures swallowed); 1803 unit tests, npm run check clean. |
||
|
|
cc07be2e7c |
fix(themes): put the previous theme back when a switch fails (#582)
Switching themes stops every other theme first — they are mutually exclusive by construction — so the interesting case is the failure: if the new theme cannot start, the user was left with NO theme at all. The old one stopped, the new one never started, and the Themes tab listed the plugin as enabled while the interface had lost its skin. What the switch stopped is now remembered and restored on failure, live fiber and disable flag together: a theme that was live before the attempt is live again and out of the disable set, while the theme that failed keeps neither — it never came up, and staying out of that set is what lets the next attempt try it again. Measured, not guessed: @bulingbuling688's branch (#582) ships a 39-scenario spec for this and the surrounding consistency work, and running it against main is what showed the gap is real — 34 of the 39 fail today, the reported one being simply "undefined" where the previous theme's fiber should be. This lands the theme half with a flows test naming the same behaviour; the rest (a single staged write path for the toggle, strict entry enable with rollback, group partial success) is still open in the PR. Verified: the new test fails with the restore removed, and again with only the disable-flag restore removed; 1799 unit tests, npm run check clean. |
||
|
|
b136602029 |
feat(client): prioritize updatable plugins at the top of the installed list (#631)
An installed row that has an update pending now sorts above the rows that do not, so the thing a user came to the tab to do is at the top. The order settles once and then HOLDS: `/installed` is a local read and `/updates` is a network probe over every package, so the list is always painted before the answer exists, and a live sort would reshuffle rows under a pointer already aiming at one. `updatesLoaded` — a boolean, not the map — is what lets the one reorder happen when the check lands. Maintainer pass on top of @Oct1AtJoe's branch, which was verified and reviewed but not updated since: - **One predicate for "has a pending update".** It had three copies that disagreed — the reminder count, the card's pill, and this ordering — the shape this repository already paid for once (`src/entry-identity.ts`: one assumption, several copies, each fixed at a different time). All three now call `isPluginUpdatable`; the two things that are genuinely per-caller policy are arguments with their reasons written down: `ignored` (the notice surfaces pass it, the row's pill does not — dismissing an update does not change whether one exists) and whether a disabled plugin counts (the reminder says no, the list says yes). - **Ignored updates no longer jump to the top.** "Ignore this update" is the user answering this question themselves, so the ordering passes `ignoredUpdateSet` and a dismissed row keeps its place. - **The freeze now has an assertion for its second half.** The existing test proved a filter round trip does not reshuffle, but a filter touches neither `updates` nor `ignoredUpdateSet` — it could not catch either being added back to the ordering's dependencies. Dismissing the update (the row's own affordance, the one input that exists without a refetch) is asserted to leave the order alone. Verified: 1798 unit tests; both mutations of the new assertions turn exactly one test red (the ignored set added to the dependencies; the ordering predicate neutered). npm run check clean, client bundle rebuilt. Co-authored-by: Oct1AtJoe <Oct1AtJoe@users.noreply.github.com> |
||
|
|
1d1afdc349 |
feat(client): say what a plugin touches, as facts, on the card (#401)
The catalog now scans each plugin at build time (awesome-dsh-plugin probe-capabilities.mjs) and publishes what it found: `capabilities` and `capabilityRedLines`. The card renders them, and the shape of that rendering is the whole feature: - **Disclosure, never a badge.** Three states, because they are three different sentences: chips when something was detected, 未检出 when the scan ran and found nothing, and 未扫描 when nobody has looked at this entry. Merging the last two — the tempting shortcut — is exactly the misreading this design exists to prevent: one of them is a statement about the plugin and the other is a statement about us. - **The blind spots sit next to the chips**, in the title of the heading that labels them: a static scan of the package, which skips node_modules and cannot see runtime-assembled URLs, dynamic imports or obfuscated code, and whose "nothing detected" is not "safe". - **The scanner's ranking is not carried.** `score` and `band` are dropped at the catalog boundary, so no surface can render a plugin as green — the badge shape that made #209 a "no" the first time. - **A capability this build has no label for shows as itself** rather than disappearing: `dynamic-code` arrived after the first integration, and a chip that vanished would read as "does not do that". - **A red line we cannot translate stays in the scanner's words.** A mistranslation of a security fact is worse than a foreign word; the two sentences this build knows are translated. No interception: nothing here blocks, warns before install, or changes what a click does. Facts on the card, judgement left to the reader — the position #209 settled and this keeps. Verified: five client tests (detected / nothing-detected / never-scanned / unlabelled name / untranslatable red line), each mutation-checked — merging the two absence states, dropping unlabelled names, and removing the red line each turn exactly one of them red. 1785 unit tests, npm run check clean, client bundle rebuilt. |
||
|
|
e7e4198981 |
review(#527): the third config site, the refresh list, and a body cap that fits
Everything the review asked for, on top of @ZhaoGY-N's two commits (rebased onto main; the third, `pnpm-lock.yaml`, was dropped — this repo has not carried that file since it moved to npm, and the commit's whole content was keeping a file main does not have). - **The official desktop host now gets `buildEnv`.** `src/index.ts` builds `MarketConfig` in three places and only two were wired, so a `buildEnv` set in cordis.yml was silently dropped on the one host the feature exists for: a GUI launch inherits no shell environment. - **`refreshMarketState` carries the field.** It is the list #435 left behind, and its rule is that any field with an independent writer must be re-read there — otherwise the next save from the stale object puts the boot-time value back. - **The route has its own body limit (256 KiB).** The default is 4 KiB and `MAX_ENV_VALUE` is also 4 KiB, so a map holding a single maximum-length value plus its JSON wrapper could never be sent: the sanitizer's ceiling has to be smaller than the transport's for either to mean anything. - **`GIT_ASKPASS` / `SSH_ASKPASS` stay allowed, and now say so.** They are the two names that can re-open a credential prompt, but pointing them at a program is the supported non-interactive way to answer one, and #587/#596 closed the *terminal* fallback rather than the program one. A user who pins them has said where the answer comes from; a user who does not still gets the closed prompt. Documented at the sanitizer, which is where the next person will ask. - **The card's hint tells the truth about scope**: these variables reach every process the market starts — installs, builds, git — not only the compiler, and the button says "Save variables" so the page has two Save buttons it can tell apart. Test gaps the review named, closed: PATH/CI are posted at the route and asserted absent from what comes back (a clean round-trip passed whatever the sanitizer did); the spawner is handed a LIVE source (freezing the copy at mount now turns a test red); and a 4096-byte value survives the request body. The flows harness now spreads the REAL `src/hot.ts` and overrides only what it needs — its hand-written copy of `buildEnvFromUnknown` had already drifted (missing the value cap), so that test was asserting the mock's behaviour. Verified: 1780 unit tests, npm run check clean, client bundle rebuilt. |
||
|
|
1e85051e6c |
test(web): install a selected compatible release on a real host (#718)
The fix in
|
||
|
|
bcb124f9e8 |
release 1.65.1: the version we recommend is the version we install
Shipped now rather than batched, because 1.65.0 ships an affordance that
cannot work: "install the newest release this host supports" resolved a
compatible version, offered it, and then the pre-flight check judged it by
`latest`'s manifest and refused it. The user is left with a button that
always fails and a dialog that keeps offering the same version — a loop with
no way out but `force`. Fixed in
|
||
|
|
310197bd8f |
fix(install): judge a pinned install on that release, not on latest
The compatibility dialog resolves a release this host can run and the install route pins it (#581) — but the pre-flight check read `latest`'s manifest anyway. So the newest release's declaration decided the fate of the older, compatible one: the market offered 1.0.0 for this host, the user accepted, and the check refused the install over 2.0.0's requirement. The version argument was carried into the log line and nowhere else; the comment above the call even claimed the opposite ("judged on the release being installed"). Reported from the field after 1.65.0 shipped. It was wrong in the other direction too, which nothing covered: a pinned release that IS incompatible went through whenever `latest` happened to suit this host — the plugin then breaks the host on the next boot, which is the exact failure the guard exists to prevent. `DiscoveryManifestIndex.lookupVersion` asks the registry for one named release (`registry/<name>/<version>`). It stays out of the index and its cache on purpose: the index is keyed by package name, a version-keyed cache would grow with every release anyone pinned, and a pre-flight verdict must not decide what the diagnostics panel sees next (#619). A manifest that cannot be read is still `null`, i.e. no verdict — absence of a claim is not a claim. The pinned version is also not a second request in the common case: the index answer already says whether the pin IS `latest`, and then it is the answer. Verified: four unit tests on `lookupVersion` (the URL it asks for, encoding, the index/cache/failure bookkeeping it must not touch, and the null-not-a- verdict answers) and two flows tests, one per direction — the first reported a 400 for a compatible pinned release before the change, the second installed an incompatible one with a 200. Five mutations, one per behaviour, each turn a named test red. 1764 unit tests pass, npm run check clean. |
||
|
|
f506bcc18c |
fix(toggle): write both layers, or neither, when a bundle plugin is switched (#696)
Disabling an ordinary bundle plugin wrote the market's own layers — the patch rows and state.json — and left `dsh.profile.bundles` alone. That array is the only thing the official plugins page's package switch reads, so the market said off while the official page said on. #707 covered the two directions of DRIFT (an enable made on the official page is followed, an unbundled package reads as off); this is the write itself. The stack now moves with the rows: a community bundle the market turns off leaves `dsh.profile.bundles`, and turning it back on puts it back. Two shapes stay out of it, both about not taking more than the user asked for: - an IN-BOX bundle is not the market's to drop from the stack (order.ts refuses to reorder them for the same reason); - a bundle whose patch names rows it does NOT insert speaks for a neighbour too. `foreignRowIds` is the new predicate: fixture-cross configures a neighbour, and leaving the stack would take that configuration away with it, which is what #147 and the fixture-cross spec exist to prevent. Note that a foreign CONFIG counts, not just `disabled: true` — leaving the stack stops the whole patch, which is what makes a carrier worth removing and a neighbour-tweaker worth keeping. The two layers also move together in the failure direction. An ENABLE that cannot write its patch rows goes all the way back — the rows it flipped, then the stack entry it added — instead of leaving behind an entry that composes a package the row layer calls off. Each row is restored the way it was: one that was disabled gets its block back, one that was not loses the `disabled: false` block the enable added, rather than keeping a force-enable in the user's own layer. The DISABLE direction keeps whatever it got: the user asked for off, and a row the patch layer refuses to flip does not make the plugin live again. The market's displayed state already followed the runtime truth (`disabled || patchDisabled || unbundled`, MarketSection.tsx), which is the second half of the report; no change was needed there. Verified: 1758 unit tests; the install e2e lane on real hosts — 13 tests on dsh 0.1.2-alpha.2 and 13 on 0.1.7-rc.1, the lane that carries fixture-cross; five mutations (stack toggle narrowed back to carriers, foreign-row guard dropped, stack withdraw removed, row rollback removed, state-aware restore replaced by "always disable") each turn a named test red. npm run check and typecheck clean. |
||
|
|
e542e560e5 |
feat(client): a clear control in every search box, and no red squiggles under plugin names (#524)
The four search fields — Discover, Themes, Favorites, Installed — had no way back to the full list except selecting the text and deleting it. Each now shows a × while there is something on screen, and one press empties the box and the filter together. It lives inside SearchInput rather than at the four call sites. #541 by @bulingbuling688 reached the same four fields from outside this component; the two reasons it had to move in only show up in the 250ms between a keystroke and its commit: - the control has to clear the DRAFT. `value` is the committed query, and between keystroke and commit the box holds text the list has not heard about — a button that only reset `value` would leave the old text on screen until the effect rewrote the draft, and would schedule its own debounce on the way out. - it has to cancel the pending query. Without that, clearing mid-debounce is followed by the old query landing anyway, and the list re-filters itself after the reader has just emptied the box. The 34px of padding that keeps a long query off the button is the same argument in CSS, and belongs to whoever renders the button: a rule four rows have to remember is a rule a fifth will forget. The host's Input renders the native element itself and forwards no ref, so focus is returned through the component's own container — clearing puts the reader back in the field they were typing in. spellCheck is off on all four: a plugin name is not prose, and red squiggles under `dsh-session-manager` are noise on a field that only takes names. The group-add dialog's search gets both behaviours for free, being the same component. Verified: 1750 unit tests; the search e2e lane on real hosts and Chromium — 7 tests on dsh 0.1.2-alpha.2 and 7 on 0.1.7-rc.1, covering hit-testing the control at every scroll step of the sticky row, native Enter/Space activation, the focus-visible outline, and long queries at 1200px and 640px; every new assertion mutation-checked (control keyed on `value`, control missing, clear without commit, spellcheck left on, focus not restored — each turns exactly one test red). npm run check clean, client bundle rebuilt. Closes #524. Co-authored-by: wang shi zhuo <147490150+bulingbuling688@users.noreply.github.com> |
||
|
|
8a44c02b31 |
release 1.65.0: a way out of two dead ends, and a host that owns activation
Two refusals that used to end with the user stuck now name their way out: - A release the profile's own `minimumReleaseAge` held back is reported as what it is — installed, working, and not the newest — with the version named and a button to take it anyway (#635). - A release this host cannot run is no longer the end of the line: the dialog searches the plugin's history for the newest release that still declares this host and offers THAT, beside the existing "install it anyway" (#581). Also in this release: a host that owns activation can now say so, and the market asks it to replay instead of creating a second loader entry (#551); the market's settings card sits where dsh 0.1.7 says a bundle's configuration belongs (#677) and 0.1.7 joins the CI matrix; the diagnostics page names the directories an interrupted update leaves behind (#663); and the store-mismatch advice stops sending people in a circle — the durable fix is a `storeDir:` line in the profile, not a one-shot flag (#715). Shipped now rather than batched because two of them are instructions the current release gives users and they are wrong: one sends them to a command that cannot help, the other offers a gamble where a compatible version exists. |
||
|
|
27a2d3bd69 |
feat(routes): offer the newest release this host supports, instead of a dead end (#581)
A refused install or update used to end there: the dialog said the release needs a newer DSH and offered one action — install it anyway, which is a decision about breaking the host that most users reaching that dialog cannot weigh. @yaojin3616 built the missing half (find the newest release that still declares this host, and install THAT), and this lands it with the review's one correction. - **`POST /dsh-market/find-compatible`** searches the package's version history through `deriveHostCompatibility` and returns the newest release whose own declaration this host satisfies. Only a CONFIRMED `compatible` passes: `unknown` is "nobody said", and pinning that as compatible is the guess the check exists to avoid. Prereleases are included and ordered properly — in this ecosystem the host line is often a prerelease and plugins declare against it by name — and an update searches only NEWER releases. Catalog membership is required, so the route is not an open packument proxy. - **The refusal dialog asks on open** and shows the answer: the version, a button naming it (primary when found), and the plain statement when nothing declares this host. `install anyway` keeps the ghost seat. - **Both routes take the pinned release**: `version` on install, `compatVersion` on update. The one judgement kept is direction (#64) — a compatible release can legitimately be older than what is installed, and an update must never be a downgrade. `force` is kept, which is what the review asked for and what the first draft removed: reading a declaration and refusing on it is not the same as being right about it, and the two roads out are different decisions — one pins a release that declares support, the other accepts that the declaration is wrong. The dialog offers both, in that order of emphasis. Not taken from the PR: its live-npm-version byline refresh, which is a separate product change to what the cards display. Twelve tests, mutation-checked at every joint: dropping the install pin, ignoring `compatVersion`, removing the downgrade guard, dropping the catalog check, not passing the found version on, and swallowing the not-found answer each fail exactly the test that covers them. 1739 unit tests green, typecheck, build, restart smoke, bundle in sync. |
||
|
|
f4ebc675b8 |
docs(assets): refresh the screenshots the README and the site both show
Both were captured on 2026-08-28 from a v1.33.0 build, and they had drifted into saying things that are no longer true of the app: - the version in the header read v1.33.0 (now 1.64.0); - the catalog chip read 2.2k (measured 4.3k today); - the tab row had no Favorites — the feature shipped since; - cards carried no host-requirement badge, which is most of what the byline was built to say. Recaptured against a real host with the REAL catalog (no fixtures), in both languages: the Chinese one is the same page after switching the host's own language, which is also how it ends up saying 插件市场 / 收藏 / 全部 (4.3k). These files are the README images, the docs homepage, and the og:image every plugin page shares, so one refresh covers all four places. |
||
|
|
b51839efbb |
feat(routes): let a host own activation, and keep the cleanup the market can still do (#551)
@sampx's seam, rebased and finished. A host that watches the profile and replays it the moment the manifest lands makes the market's own hot mount a SECOND loader entry for an id the live composition already serves — duplicate prefix routes, and "restart required" about a plugin that is already up. Where the host publishes `desktopProfiles.pluginActivation`, the market now asks it to replay and reports what it answers, instead of mounting on its own. The seam is optional by construction (`pluginActivation?` on the service the market already reads, not a new hard gate): a host that only knows `current` keeps working unchanged, and `ctx.inject` is not asked to demand a capability no user asked for. Two things changed from the first draft, both from the review: - **`setEntryDisabled` still runs when the host owns activation.** The draft skipped it (`entryDisabled = false`), which is #213's exact shape: a package with two activation sources losing half its cleanup because the other source reported success. The host owns the entry it made; the market owns any entry it can still see — a machine that ran plain `dsh web` before the host took over can have one — and success from one is not evidence about the other. It costs a name scan when there is nothing to disable, and the test pins the scenario rather than the call. - The install path keeps the author's adoption logic on the plain-host side: a loader entry whose fiber is already up is ADOPTED rather than re-mounted, which is the same collision one step earlier, for hosts that self-activate without publishing the bridge. Four tests, each mutation-checked: ignoring the bridge on install fails two, ignoring it on uninstall fails one, and restoring the draft's skipped cleanup fails one — the last of which is the review's decision, now load-bearing. 1729 unit tests green, typecheck, build, restart smoke, bundle in sync. |
||
|
|
964e137f0d |
feat(install): say when a release was held back, and let the user ask for it (#635)
A fresh install pins to the registry's latest so pnpm's fresh-release hold cannot substitute an older version silently (#621), and when the profile set `minimumReleaseAge` on purpose the market declines the one-shot bypass and falls back to the bare name (#594) — which installs the mature version and reports plain success. The plugin is on disk and works, and the user is never told that a newer release exists or why it is not the one they got. @FuRongJun-1999 found this and wrote the detection; the shape is theirs. What this lands is that shape with the two corrections from the review: - The hold is a SUCCESS with a caveat, not a failure. Reporting it as `ok: false` — as the first draft did — shows "install failed" beside a plugin that is installed and working, which is the one thing this project treats as worse than saying nothing. The row keeps its ✓ and gains a reason. - The escape has an entry. The bypass the fresh path refuses to assume it has is exactly what an explicit click gives it: `force: true` runs the PINNED add with the one-shot override, and the row offers that as a button naming the version. Detection without a way out would only have made the hold louder. `heldRelease` travels in the install response, the record carries it as `warned`, and the Tasks panel renders the notice plus "install <version> anyway" — beside the sentence describing the problem, the same place the build-approval action already sits. Four tests, each mutation-checked: dropping the response field fails one, ignoring `force` fails another, taking the branch away fails the client test, and forgetting the flag on the button fails it too. 1725 unit tests green, typecheck, build, restart smoke, bundle in sync. |
||
|
|
1b7505dee2 |
fix(pnpm): the store-mismatch advice was a loop, and #244's conclusion was wrong
Every install and update fails with ERR_PNPM_UNEXPECTED_STORE after $DSH_HOME moves to another mount point: pnpm 11 re-selects a store for the new mount while the node_modules built before the move keep the old one recorded, and pnpm then refuses add/remove/install for that profile (#715). The message we showed told the user to run `pnpm install --store-dir <the linked path>` once. Measured on pnpm 11.7.0: that command succeeds and does NOT rewrite the store recorded in node_modules/.modules.yaml, so the very next market-spawned command — which passes no flag — fails identically. The advice sent people in a circle, on a failure that already refuses every operation. What works, and is what the message now says: write the RECORDED store into the profile's pnpm-workspace.yaml as a top-level `storeDir:` (camelCase). Verified both directions on a profile-shaped tree: with the line, market- shaped `pnpm add -w` commands pass one after another with nothing reinstalled; without it, they fail. This also falsifies the reasoning this code carried from #244 ("on pnpm 11 the store can only be set by CLI flag") — a conclusion drawn from `pnpm config get store-dir`, which is not a valid probe (it answers the value on 11.7 and undefined on 11.22 while the setting works on both). `recoverable` stays false, for a reason that survives the correction: either choice is the user's. Pinning the recorded store keeps a path that may be on a disk they just moved away from; adopting the store pnpm now resolves makes pnpm purge and re-download the entire node_modules (it stops for confirmation and aborts without a TTY — ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY, measured). Three tests, mutation-checked: removing `storeDir` from the message fails all three that assert it. |
||
|
|
43d42a1712 |
test(e2e): count mounts in the liveness marker, and put 0.1.7 in the matrix
The marker every web spec reads as ground truth was wrong in one shape, and that shape was already in the same file: a carrier bundle (#156) inserts an entry naming ANOTHER package, so `dshm-e2e-fixture-b` is mounted by two entries at once. They resolve to the same module URL, the instance is shared, and `apply` runs once per entry — so one entry's dispose deleted the file while the other entry kept running the plugin. I read that as a bug in dsh 0.1.7, wrote it up as #717 with a reproduction, and was wrong: the dispose/apply ORDER differs between 0.1.7 and 0.1.2-alpha.2, and only the ordering decides whether the shared file survives. Asking the host itself settled it — its Plugins page reports the plugin Running while the file was absent, and the market's own verdict agreed. So the fixtures now count mounts and delete the file only when the last one leaves, which is what every spec already assumed the file meant; the host and the marker agree on 0.1.7, and the full lane is 44/44 on both lines. With that, the last blocker for 0.1.7 in CI is gone. It joins the matrix on Linux (the newer lines are where host-API breakage lands, and that is platform-independent), and it installs AS PUBLISHED: the #684 pins exist for the old CLIs, whose cordis plugins float past what their boot can use, while 0.1.7 declares its own ranges and boots on them — measured, full lane green unpinned. Pinning it would have had CI testing a composition no user gets, on the line where that matters most. |
||
|
|
86d35cbd78 |
docs(readme): the restart switch has no home on 0.1.7, say so
0.1.7 derives a plugin's settings from its own Config schema and serves no plugin-registered namespace, so the market's settings surface is absent there by both sides' design — not a gap this project should close by relocating the market's own card (which now lives where the host says a bundle's configuration belongs, #677). `allowRestart` stays what the card's own note has always said it is: a config option, whose audience is the person who wrote the deployment. The profile patch works on every host, so the README now names it as the route on 0.1.7 instead of pointing at a switch that is not there. |
||
|
|
163cf769f0 |
fix(client): put the settings card where a 0.1.7 host says it belongs (#677)
0.1.7 moved a plugin's own configuration onto its bundle's page in the
sidebar's Plugins page, and its slot contract states where a THIRD-PARTY
bundle's configuration goes: `plugins.item` is "OCCUPIED by the official
settings pages", and "a bundle's configuration belongs in
`plugins.bundle.config` or `plugins.row.config` instead".
That is the same seat `settings.plugin.item` was on the older line, under its
new name — not a location chosen here. My first attempt registered into
`settings.plugins.tab`, which is the section for the plugins a deployment
SHIPS ("Built-in plugins"), and was wrong; a probe of a real 0.1.7 host is
what showed it, and this is what the host's own documentation names instead.
Detection is the slot itself: `slots.inject` fires only when a host declares
the slot, so every release before 0.1.7 never runs this and no version string
is consulted. The summary view returns null — the market's description is
already the one-liner there — and `page` renders the card.
Verified against real hosts, both directions: on 0.1.7-rc.1 the card renders
on the bundle page (`Update channel` + `Download region` asserted by the
spec), and on 0.1.2-alpha.2 the old plugin-configuration flow still passes
untouched (2 files / 14 tests green there).
The existing guard test earned its keep: it failed on the new registration,
which is exactly what it is for, so it now says what it was checking — a host
declaring neither card seat still gets the market, and a host declaring the
new one gets the card — with the fake `slots.inject` firing only for declared
slots, as a real host does.
|
||
|
|
34658ad963 |
test(e2e): ask each host generation what it can actually do (#677)
0.1.7 changed the settings contract under the market, and two specs were asserting one generation's shape against both. Neither was the market's bug: the market's plugin-configuration card cannot be dispatched on a host that serves no third-party namespace, and that is the host's model rather than a gap to close by moving the card somewhere else. The difference is now REPORTED, by the side that knows it: the market offers its namespace and sees what the service answers, so `/status` carries `settingsNamespace: pending | registered | unsupported-by-host`, and both specs branch on that instead of on a version string. Each branch asserts something real — a namespace on a host that takes one, the absence of the plugin-configuration page on the host that replaced it, and the market's own `settings.section` on both. Also fixed in the harness, found while trying to verify the old line locally: `scripts/install-e2e-host.mjs` pinned its cordis overrides in `pnpm.overrides`, which pnpm 11 no longer reads — measured, the install landed loader@1.0.5 + hmr@1.0.19 and the host died with the very #684 error the script exists to prevent, while `pnpm-workspace.yaml` held nothing but an `allowBuilds` stub pnpm wrote for itself. The overrides now go in the workspace file too (CI pins pnpm 10, where the package.json form still works, so both are written), plus `strictDepBuilds: false`, which is the other half of that stub. Verified: full lane green on 0.1.2-alpha.2 (14/14 in the two files); on 0.1.7-rc.1 the same files pass with 12/13 in install.e2e.ts — the remaining failure is a real behaviour difference, not a spec shape, and is reported separately. |
||
|
|
6b93028b9f |
docs(readme): document what shipped since the last pass, in both languages
Five features were reachable in the app and absent from the README, which is the first thing a new user reads and the npm page's own copy: - **Groups** and **Notes** — the two organising features, beside Favorites. Both are local state in the profile's `state.json`, and the notes bullet says the part that is not obvious: a note REPLACES the author's description on that row. - **Updates** gains the two halves of acting on an update: the "What changed" link on a row with one pending (release notes, or the commits when the version cannot be aligned), and ignoring a notice for the rest of the boot. The scope is the point — a restart brings it back, and ignoring is never the same as turning it off. - **Diagnostics** gains the leftover-directory listing from #663. - **Data source** said nothing about the China route, so a reader there was told to pick a mirror by hand while the market was already reading the catalog from npm first. The origin is still last in that list, which the paragraph now says rather than implying the host is never used. Also refreshed the catalog count: 2300+ → 4200+ (measured: 4275 today). It is a number that only ever moves one way, and a stale one is the kind of detail that makes a reader doubt the rest. |
||
|
|
7db213ab37 |
feat(diagnostics): name the directories an interrupted update left behind (#663)
A leftover is invisible from the profile: nothing declares it, `pnpm install`
will not repair an empty shell ("Already up to date"), and the only thing a
user who goes looking finds is an empty directory with no explanation. Two
shapes — a package directory without a readable `package.json` (what a
lock-blocked update leaves) and pnpm's `<name>_tmp_<pid>_<n>` staging
directory — and neither can be cleaned from in here in the case that produces
them, because the plugin's own process holds the directory open. Naming them,
and saying which is merely junk, is the part this process can do.
- `findResidualDirectories` in src/check.ts, pure filesystem analysis like the
rest of the report. Bounded: the top level in full, plus one level inside
the virtual store (where a dependency's temp directory appears), not the
whole store — a page the user opens by hand should not walk thousands of
directories on Windows, and the store's own temp directories already belong
to `cleanOrphanedStoreTmp`.
- DECLARED packages with a broken directory are deliberately not listed: the
bundle layers above already say they cannot load, and calling one of those
junk would tell the user to clear a package the profile is asking for.
pnpm's own dot-directories (`.pnpm`, `.bin`, `.modules.yaml`) are not
packages either, and neither are the symlinks it links every package with.
- Reported structurally, NOT as warnings: a leftover harms nothing today, and
a warning on every profile that ever had an interrupted install is how a
list stops being read. The section renders without the alarm style.
Three tests, each checked against its own mutation: not excluding the dot
directories fails both server tests, returning no results fails the listing
test, and renaming the section fails the render test. 1719 unit tests,
typecheck, build, restart smoke, bundle committed in sync.
Held for the next release: informational, nothing is broken without it.
|
||
|
|
8adcc83762 |
release 1.64.0: a failed update no longer costs you the app
- A plugin whose update was blocked by open files, and whose previous build was already incomplete, is no longer left DECLARED. That combination is what made the next start die while composing the profile — on Desktop the window never opened, and the only way back was uninstalling the plugin by hand and losing the pin. The declaration is dropped instead, the directory is left exactly where it is, and the market says what happened and how to get the plugin back (#663). It ships now rather than in the next batch because the failure is not a wrong message: it is an app that will not start. |
||
|
|
99ba7a2eca |
fix(update): stop declaring a plugin the next boot cannot compose (#663)
The failure this closes: an update blocked by open files leaves the target directory incomplete, `keepLockedBuild` cannot keep the previous build, and the profile goes on declaring a package whose `package.json` is missing. The next start dies in composition — on Desktop the window never opens — and the reporter's only way out was uninstalling the plugin by hand, losing the pin. Both remedies the issue proposed are impossible in the case that produces it. The lock is on the directory itself, not on a file inside it (measured: `EBUSY` renaming the already-emptied directory), so moving it into a quarantine or deleting it is the same rename and delete pnpm was just refused. What the market can do, and what is actually enough, is drop the DECLARATION: composition stats declared packages, so no longer declaring it removes the failure outright, touches nothing the user owns, and leaves the directory where the reinstall can reach it after DSH quits. - `dropFromManifest` (deps + `dsh.profile.bundles`) on the branch that had nothing left to keep, keyed on `hasLoadableEntry` — the market's own answer to "can this build load", and the one the message we replace already asserted. A build worth keeping is still kept, which is the boundary test. - `MarketState.brokenPlugins` records what was dropped and the spec to reinstall, because the plugin is then gone from the installed list and nothing else can explain the absence. Optional on the way in, like `notes`, and refreshed by `refreshMarketState` so a read-back cannot eat it (#435). - `/installed` carries it rather than `/status`: that is the refresh every install and update already triggers, so the notice appears on the failure and leaves on the reinstall without a page load. Cleared explicitly on a successful install/update — a predicate that hid the entry when the package reappeared could not tell "reinstalled" from "declared again by hand and still broken". - The installed tab shows it with the reason and a button to the catalog, `xN`-free copy in both languages (#663 asked for the next action in the UI, not only in the log). Five tests, each verified against its own mutation: dropping the declaration fails two, never clearing the notice fails only the third, and the client notice never appearing fails the first two. 1716 unit tests green, typecheck, build, restart smoke, bundle committed in sync. |
||
|
|
05ceb78972 |
release 1.63.0: a fatal verdict on a bundle that is running
- A Desktop build's own bundles are no longer called missing (#676). The check knew two official names — one bundle, one loader — and everything else a Desktop ships from inside `app.asar` came back as "the profile will fail to boot". Three reporters, one rule: what makes a probe blind is the LAYOUT (an archive) and what makes the name in question the host's is the scope (`@deepseek-ai/`), and both lists are now gone rather than extended. This one costs more than a wrong warning. The suggested remedy is to delete the bundle from `dsh.profile.bundles`, and following it turns Agent Teams off for real — which is why it ships now instead of in the next batch. - Unchanged, deliberately: a community bundle that resolves nowhere is still fatal, on Desktop as anywhere else. The profile's own node_modules is probeable, so that verdict is true. |
||
|
|
98a182cc04 |
fix(check): an official bundle is unknown on a Desktop install, whoever made it (#676)
Three reporters, one rule stated three ways, and two of the ways were fixed name lists. `DESKTOP_HOST_BUNDLES` held exactly one bundle (`@deepseek-ai/dsh-experimental-agent-team-profile`) and `DESKTOP_HOST_LOADERS` exactly one loader (`@deepseek-ai/dsh-mcp-client`); anything else a Desktop build ships and this process cannot probe got "bundle package is not installed — the profile will fail to boot". yuj-029 hit it with `-profile` AND `-web-profile` in the same 7-bundle profile, with the same reason each time: the name was not on the list. KannaKuron supplied the part that makes it mechanical — inside `resources/app.asar` a filesystem probe answers "absent" for every name, the host's own included, so the list could only ever be a list of the names someone had already reported. What the rule actually is: the LAYOUT is unreadable (an archive — tested by `isPackagedDesktopInstall`, now any `app.asar` segment rather than only the `app.asar/dsh` anchor shape, which is not the shape `dshHostInfo` discovers), and the name is DeepSeek's (`@deepseek-ai/` — only they publish the host), so the host may be supplying it from somewhere this process cannot see. That reads as unknown. Both lists are gone; nothing replaced them. Kept: a community bundle on a packaged Desktop is still fatal, and so is an official bundle on a readable installation — there the profile's own node_modules ancestry and the installation's are both probeable, so "in neither" is a true statement about the next boot. Three tests fail if the scope bound is dropped, including two that predate this change. Also pinned, from qikairo7's counterexample: `@deepseek-ai/dsh-agent-preset` existing ONLY in the installation, referenced by a user-patch row, is accepted — because the installation's own node_modules inventory is the authority. Nulling that inventory (falling back to the curated seed) fails the test, which is the point: the seed would not have known the name. |
||
|
|
8fd03535e8 |
release 1.62.0: the ssh half of the git hang, and a version label that tells the truth
- A git install that falls back to ssh no longer waits on a passphrase prompt nobody can answer (#587's ssh half, from @JINITAIMEI121's #596, finished in #713). pnpm tries https first — measured — so this covers the second attempt: a private repository, where https fails and ssh asks a question on a terminal a spawned child does not have. `BatchMode=yes` turns that into a fast failure, and ONLY when the user has expressed no ssh identity anywhere (GIT_SSH_COMMAND, GIT_SSH, or core.sshCommand in git config — GIT_SSH_COMMAND silently overrides all three). The failure message names ssh-agent, because git's own words ("Permission denied (publickey)") send the reader to check a key that is usually fine. - The version on a plugin card says where it comes from (#712). It is the catalog's copy, refreshed daily, and the tooltip called it "npm latest" — beside a number that is not npm's latest. It now names the catalog's own build date, so a plugin published since the last refresh reads as data age rather than as a bug. |
||
|
|
144d9343ed |
fix(client): say where the version on a card comes from, and how old it can be (#712) (#714)
Reported by @ziduup: the card showed v0.3.4 for a plugin npm had at 0.3.6. The number is the CATALOG's `version`, refreshed once a day — not a live npm lookup — so a release published since the last refresh shows the older number until the next one. That part is by design (one catalog fetch instead of one npm request per card). What was not by design is the tooltip, which said "npm latest" beside a number that is not npm's latest. That is how a question about data age becomes a bug report, and the fix is to answer it in place: the tooltip now says the version is the catalog's copy as of its last refresh, with the catalog's own build date — `<catalog>/plugins.json` carries `updated`, the client already read it to detect a changed catalog, it was just never shown. Falls back to "the catalog updates daily, so this can lag npm" when the date is absent, which is also what an empty string counts as. 1708 tests; full e2e, 9 files / 44. |
||
|
|
795b4f0b4c |
fix(cli): close the ssh prompt too, without taking over the user's identity (#596) (#713)
Takes over #596 by @JINITAIMEI121, whose half of #587 is right and whose branch has not compiled since 09-15 (conflict markers committed) and rests on a premise I measured to be backwards. Two days after I said so on the PR, and nine after their last commit, so it is finished here rather than left as a PR nobody can merge. What survives from theirs: `GIT_SSH_COMMAND` with `BatchMode=yes`, so an ssh passphrase or host-key question fails fast instead of waiting on a terminal nobody is watching. What changes: - The premise. They wrote that hosted-git-info resolves the ssh form, so pnpm clones `git@github.com:…`. Measured with a `GIT_SSH_COMMAND` sentinel that always fails: pnpm's clone of `github:o/r#path:/sub` completes without ever calling it, so https is FIRST and ssh is the fallback after https fails — the private-repository case. That matters because #593's `GIT_TERMINAL_PROMPT` already covers the path that actually runs first; this covers the one that runs second. - The regression they did not handle. `GIT_SSH_COMMAND` overrides `core.sshCommand` and `GIT_SSH` — measured, with core.sshCommand set the environment wins and the configured command never runs — so setting it unconditionally would replace the identity of every user who chose one, and BatchMode would then break the passphrase installs that work today. It is now set only when the user has expressed no preference anywhere: both variables, blank counting as unset, and `core.sshCommand` read from git config (memoized for the process, injectable for tests). - The message. git's own words are `Permission denied (publickey)`, which reads as "your key is wrong" — the key is usually fine, it wants a passphrase, and the channel that would have asked is what was shut. That failure now names ssh-agent and GIT_SSH_COMMAND. Every guard mutation-checked: not checking the user's identity, not setting the variable, not classifying the failure, and dropping it from spawnEnv each turn a test red. 1706 tests; full e2e, 9 files / 44. Co-authored-by: JINITAIMEI121 <JINITAIMEI121@users.noreply.github.com> |
||
|
|
ef4192a7ca |
release 1.61.0: no dangling links after uninstall, and failures that explain themselves
- Uninstalling a plugin on a desktop build no longer leaves a dangling link in the host deployment's node_modules (#662, fixed by @qikairo7 in #708). The boot projection creates those links and never removes them, so after an uninstall they point at a package directory that is gone — and any tool that walks node_modules with lstat trips over them (`rg` exits 2 with `os error 2`). Only a link whose target is exactly THIS profile's removed copy is unlinked: never a real directory, never another profile's link, never a live bridge. - A failure the market cannot classify now shows the diagnostics file the dsh CLI pointed at, instead of the single line it prints (#672). That line — `dsh: pnpm failed; diagnostics: <path>` — is all the CLI leaves on stderr; pnpm's actual output is in the file. Bounded to the last 8 KB of an absolute path that is a regular file. |
||
|
|
d300235fa2 |
fix(install): show the diagnostics file the dsh CLI points at (#672) (#711)
Reported by @shenhuanageshei (and the shape recurs in #244, #192, #138): `dsh plugin` redirects pnpm's whole output into a file and prints only dsh: pnpm failed; diagnostics: <path> so every failure the market cannot classify reads as one unhelpful line — whatever the cause, including the causes pnpm wrote down in full. The market now reads the tail of that file and shows it under a labelled heading. Bounded on purpose: absolute paths only, a regular file, at most the last 8 KB. The path comes from our own child, but the market only ever wants the end of a log, and reading an arbitrary amount of an arbitrary file is not worth what it could add. Two guards, each with a test that fails without it: a relative path is refused even when such a file exists (the child's cwd is not this process's), and a large file yields only its end. The report's other two suggestions are not taken here. A blanket retry is what withHoistRecovery already does for the classes it can identify; a third, "install by calling pnpm directly", would bypass the host's own plugin pipeline — the boundary #672's own discussion and #702 both argue to keep. 1703 tests. |
||
|
|
7713812fc7 |
release 1.60.0: the market and DSH's own plugin page stop contradicting each other
- A plugin enabled on DSH's own Settings → Plugins page is no longer switched back off by the market (#696, reported by @KannaKuron). The page enables a row by flipping it to `disabled: false` in the shared patch file; the market kept its own disable list and its self-heal guard put the plugin down again within a second, and again on every boot. A row explicitly enabled there, with none disabled, is now read as the newer decision and the market's list follows it. - A plugin the page turned off by removing it from `dsh.profile.bundles` shows as off in the market, and enabling it there puts it back. Before, the market showed it enabled while nothing loaded it, and toggling it in the market could not bring it back. - The desktop app's own bundles are no longer reported as "not installed — the profile will fail to boot" while they are running (#676, reported by @yuj-029 and @KannaKuron). The warning told users to reinstall or remove a bundle that was working. While the DSH installation cannot be located, an official bundle is now unknown rather than missing. - pnpm 12's native engine running out of memory is named, with the way around it, instead of shown as a raw Rust abort (#701, reported by @ChongCyrus); and a fresh install that fails half-way now restores the lockfile as well as the manifest. |
||
|
|
97cd106cdd |
fix(toggle): stop fighting DSH's own plugin page over what is enabled (#696) (#707)
Reported by @KannaKuron with both managers' mechanisms traced in source: DSH's Settings → Plugins page and the market share one layer, the user patch file, and each keeps another the other cannot see — the page switches a package by adding it to or removing it from `dsh.profile.bundles`; the market keeps a disable list in state.json that its self-heal guard and boot replay enforce. Verified on the market side: the guard pushes down any fiber whose name is on that list, unconditionally, and the installed listing never reads dsh.profile.bundles. Two of the three reported scenarios are fixed here: - An enable made on the official page is followed, not undone (C). The page enables a row by flipping it in place to `disabled: false`. The market removes a name from its list BEFORE writing its own enable, so a package on the list whose rows are explicitly enabled — with none disabled — can only reflect a newer decision made elsewhere. The guard and the boot replay now drop it from the list instead of switching it back off. - A package the official page turned off by removing it from dsh.profile.bundles reads as off, and enabling it in the market puts it back (A). Before, the market showed it enabled while nothing composed it, and toggling it flipped patch rows and left it out for good. The listing reports it under a new `unbundled` field that the client folds into the switch state; enabling re-adds it for the next boot and brings it up now, so a restart is asked for only if it is not live afterwards. Scenario B — the official page showing a market-disabled package as enabled at the package level — is a question of which layer the market should write, and is left for the issue. The testbed's host `on` was a no-op, so the guard had never been exercised by a test. It now records listeners. Writing these cases also caught two tests of my own that could not fail: a fixture whose entry was already disabled, and an assertion that compared update()'s arguments exactly when it is called with three. Each fix is now mutation-checked against the test aimed at it. 1684 unit tests; full e2e, 9 files / 44 tests. |
||
|
|
6cd1247e74 |
fix(install): name pnpm 12's native out-of-memory abort, and roll back the lockfile a failed install leaves (#701) (#706)
Reported by @ChongCyrus with an A/B on pnpm 12.5.1 (Windows, 8 GB): every run with `autoInstallPeers: false` in the workspace file — DSH writes it into every profile — aborted in pnpm-native at ~5 GB peak RSS with "memory allocation of 5368709120 bytes failed", exit 3221226505 (0xC0000409, how a Rust abort ends on Windows); every run without it passed at ~80 MB. The reporter has taken it to pnpm. Not reproduced on macOS here (both configurations exit 0 on a small profile). The market showed the raw abort. It is now classified `native-oom` with a message that says the two things a user needs: the plugin is not the cause, and pnpm 11 is the way around it until pnpm fixes it. No automatic retry: on the reporter's own data every run with the key fails, so a retry only doubles the wait. Also from the report, and confirmed in the code: a failed FRESH install restored the manifest but not pnpm-lock.yaml. pnpm writes the lockfile before it links, so a run that dies in between left a lock naming a package package.json never got. The update route has always restored both; the install route now does too, reusing its helpers. Both mutation-checked. 1678 tests. |
||
|
|
e4ba7dc95f |
fix(check): an official bundle is unknown, not missing, while the installation is out of sight (#676) (#705)
Reported by @yuj-029 and @KannaKuron on #676, against both a packaged desktop build and the official desktop client: `@deepseek-ai/dsh-experimental-agent-team-profile` was reported as "bundle package is not installed — the profile will fail to boot" while its three entries were active in the running host. On the official client that warning surfaced beside every failed install. A desktop build ships more in-box bundles than the three in INBOX_BUNDLES, and #703 added this one only behind `isPackagedDesktopInstall`, an app.asar path-shape test — which cannot pass when the installation is not locatable at all, the state yuj-029's log shows ("dsh host: not locatable from this process"). The principle is #369's: when the installation cannot be seen, what it may supply is unknown, not missing. Only DeepSeek publishes under `@deepseek-ai/`, so while the installation is out of sight such a bundle is now marked unresolved rather than fatal. What stays fatal is pinned by tests: a community bundle absent from the profile, and an official bundle absent from an installation that IS located. Reverting the condition turns the new case red. 1677 unit tests; full e2e, 9 files / 44 tests. |
||
|
|
20d9245080 |
release 1.59.0: the official desktop app can install again, and one approval no longer breaks pnpm 10
- On the official desktop app every install, update and uninstall failed with `profile "desktop" is managed exclusively by the Electron application` (#702, reported by @KannaKuron). #644 had correctly aimed them at the `desktop` profile — through `dsh plugin`, which refuses that profile by NAME. They now go through the app's own `pluginManager` service (built by @svptk87tx8-dotcom in #687, taken over in #703), with detection following the CLI's rule rather than an install-anchor shape that could not be verified. A failed enablement is no longer reported as a successful install; an operation the manager cannot express points to Settings → Plugins. - On pnpm 10.26 through the latest 10.x, and 11.0 through 11.5, approving build scripts for a git plugin broke every later package operation in the profile (#704). pnpm reads an allowBuilds key as a version range, and the git-source keys the market writes fail the whole workspace file with "Invalid versions union". The market now recognises that failure, removes only the source-form keys — bare names authorize a git dependency on those versions — and retries once, which also repairs profiles an earlier approval already broke. Measured across pnpm 9.15 to 12.4. - "Allow build scripts and retry" works for a TRANSITIVE git dependency (#698, reported by @Takeoff0518). The refused package was in neither node_modules, the manifest nor the catalog, so the route answered "no installed packages given" and looped. pnpm's own refusal in this process is now an anchor: the key written is the one pnpm printed. - An update whose new commit renamed the package is rolled back instead of leaving a profile the desktop app refuses to start (#694, by @po-et in #699). pnpm installs the renamed package under the old dependency key and exits 0 — measured. - On dsh 0.1.7, the market no longer throws inside the settings service, which lost `register` (#677, stop-gap in #693); and a self-hosted git remote's build-approval key is kept as spelled, with or without `.git` (#695). Also: the private vulnerability reporting form is actually enabled now (#700). It was not, despite SECURITY.md saying so. |
||
|
|
491f67a868 |
fix(approve-builds): approve a refused transitive git dependency, and stop git keys breaking pnpm 10 (#698) (#704)
Reported by @Takeoff0518: "Allow build scripts and retry" answered `no installed packages given` and the install looped. Reproduced against the reporter's plugin with real pnpm. Two bugs, one found while measuring the other. 1. The refused package was a TRANSITIVE git dependency (`@dsh-external/dsh-super-injector` inside dsh-routing-suite). The approve route only allows names it can anchor to node_modules, the profile manifest or the curated catalog, and a transitive dependency of a failed install is in none of them. pnpm's own refusal in this process is now an anchor too: the client names the package, and what is written is the bare name plus the key pnpm PRINTED for it — never request text. pnpm 11.8 prints `name@https://codeload…/tar.gz/<sha>`; pnpm 10 prints an onlyBuiltDependencies example with the bare name, which is what it needs. The record supplements the catalog anchor, never replaces it: for a catalog plugin the derived keys are what pnpm 11.21 matches. 2. Measured while doing that: pnpm reads an allowBuilds key as `name@<version union>`, and on 10.26 through the latest 10.x and on 11.0 through 11.5 a git or archive source there fails the WHOLE workspace file — `Invalid versions union … Use exact versions only` — so every later pnpm command in the profile fails, including an install of an unrelated npm package. Those are exactly the keys the market writes for a git source (#68, #285, #637). Matrix, bare / git+https / codeload: 9.15, 10.0–10.25 ok / ok / ok (allowBuilds not read) 10.26–10.29 ok / BROKEN / BROKEN 11.0–11.5 ok / BROKEN / BROKEN 11.6–12.4 ok / ok / ok pnpm 10's latest release is in the broken range. Rather than guess the version `dsh plugin` will run, the market now recognises the failure (`unparseable-build-key`), removes only the source-form keys from allowBuilds — bare names and explicit `false` stay — and retries once. On those versions the bare name is what authorizes a git dependency (measured on 10.29). This also repairs profiles an earlier approval already broke. Verified end to end against real pnpm 10.29.3: first run INVALID_VERSION_UNION, keys removed, retry exit 0. setAllowBuilds' YAML read/write is factored into two helpers so the remover shares its CRLF and duplicate-block handling instead of copying it. Every piece mutation-checked. 1662 unit tests; full e2e, 9 files / 44. |
||
|
|
fa8722a07d |
fix(desktop): route the official desktop profile to its plugin manager, detected by name (#702) (#703)
* fix: use official desktop plugin manager * fix(desktop): route the official desktop profile to its plugin manager, detected by name (#702) Takes over #687 by @svptk87tx8-dotcom, whose routing through the official `pluginManager` service is kept; it had not moved since review, and #702 shows every install, update and uninstall failing on the official desktop app — a regression from #644, which fixed the profile detection and so sent those operations to `dsh plugin --profile desktop`, which the CLI refuses. Three changes to the original: - Detection follows the CLI's own rule. `@deepseek-ai/dsh` 0.1.7-alpha.2 refuses a profile by NAME, `profile.toLowerCase() === "desktop"`, so a profile with that name can only come from the Electron app. #687 keyed on an install anchor ending in app.asar/dsh/package.json; the official desktop host is not published, that layout could not be checked, and a host that missed the test fell back to the CLI and failed every install — the #702 reporter's host is `@deepseek-ai/dsh-desktop-host`, a different package. A third-party shell still announces itself through `desktopProfiles`, and this block runs only when that is absent. - A failed application is never exit 0. The official ChangeResult says "successful installation can proceed to enablement", so stage `enable` + application `failed` follows a pnpm run that exited 0; mapping packageResult.exitCode there reported a failed enablement as a successful install. `overridden` — a change the manager KEPT, whose live state a user patch decides — is success with a note, not a failure that sends the update route into a rollback. - `update` is refused with a pointer to Settings → Plugins instead of being rewritten to `name@latest`, which crossed the installed range and ignored the release channel. So is an absent manager: the user gets something to do, not "no CLI fallback is allowed". Existing cases that used a launcher profile NAMED `desktop` to mean "any launcher profile" (#639) or "a profile whose own package manager the CLI path uses" (#653) now name it `work`: under the CLI's rule a `desktop` profile never reaches the CLI path, so those cases described something that cannot happen. The #702 case — `desktop`, no anchor — is added, and matched case-insensitively as the CLI does. Each change mutation-checked. 1666 unit tests; full e2e on a real host, 9 files / 44 tests. Co-authored-by: svptk87tx8-dotcom <svptk87tx8-dotcom@users.noreply.github.com> --------- Co-authored-by: Alex Lin <alexlin215@icloud.com> Co-authored-by: svptk87tx8-dotcom <svptk87tx8-dotcom@users.noreply.github.com> |
||
|
|
e2c53bcb5f |
fix(settings): stop throwing on dsh 0.1.7's settings service, which has no register (#677) (#693)
Reported by @dzwalker. Verified against the published packages: @deepseek-ai/dsh-settings@0.1.7-alpha.2's SettingsService has `describe` and `update` and no `register` — 0.1.5-rc.3's has all three. Namespaces are now derived from a plugin's Config schema. The `settings` service still exists, so the market's inject callback ran and `register` threw a TypeError that cordis swallowed. Both entry points (web and Desktop) now check for the method. Without it the composed entry stands, and the host log says why the allowRestart switch is absent — once, instead of a swallowed TypeError on every boot. This is a stop-gap and says so in the file header. The migration to the 0.1.7 model — Config-schema-derived settings on the host, `configForms` and the `settings.plugins.tab` slot on the client, where `settingsScope` is also gone — needs a real 0.1.7 host to verify against, and is tracked on #677. A pre-0.1.7 case pins that the guard does not swallow a working register. Reverting the guard turns the 0.1.7 case red. 1653 tests. |
||
|
|
4d441ee3e5 |
release 1.58.0: updates work on dsh 0.1.7, and off-and-on stops pretending
- On dsh 0.1.7-alpha.1 EVERY plugin update was refused and rolled back with "bundle declares no dsh.bundle.patch — the profile will fail to boot" (#688, reported by @eddiedon with the three target manifests checked). The message was not about the package being updated. The update route's pre-boot trial builds every bundle layer, and the in-box @deepseek-ai/dsh-web-app declares its patch as a LIST of five files; the analyzer required a string (#676, fixed in #686), and the route reported the first error without its layer name, so it read as blame for whatever the user had just updated. The trial now composes every declared file, not only the first, and the failure text names the layer. - The diagnostics page no longer calls the official 0.1.7 layout unbootable for the same reason (#676, reported by @KannaKuron). The report's second finding — that a user patch may load a package only the dsh installation has — was checked against the loader and left as it is: user-patch entries resolve from the profile unless the host passes bareModuleBaseUrl, and the CLI does not. - Turning a plugin off and on after an update no longer reports it as live while the process still serves the old build (#685, reported by @HorusJiang with a module-scope probe). Re-enabling re-creates the fiber around the SAME module URL, which Node serves from its cache — the profile layout is hoisted, so an update rewrites files in place. The toggle reply now gives the listing's verdict (restart), and a hot mount no longer clears the "replaced while live" flag, whose comment claimed such a mount imports the module as it is on disk now. Measured end to end: after update → off → on the fixture still runs 1.0.0. Also: the web-e2e lane boots again (#684, #690). Two upstream releases (cordis-plugin-loader >= 1.0.4, cordis-plugin-hmr >= 1.0.18) stopped the pinned CLIs booting; the lane now installs its host with those two pinned. |
||
|
|
042754e542 |
fix(toggle): stop calling an off-and-on "live" while the old module runs (#685) (#691)
Reported by @HorusJiang with a module-scope probe and a positive control: after an update, turning a bundle-layer plugin off and on destroys and re-creates its fiber (its route 404s, then 200s), but the new fiber evaluates the SAME module URL, and Node's ESM cache serves the old one. The profile layout is hoisted, so an update rewrites files in place and the URL never changes. The toggle reply said `activation: live, hot: true, restart: false` regardless — it asked the loader's inventory, where the name is present, not whether the code was new. Reproduced on a real host before changing anything, in tests/web/update.e2e.ts: the fixture reads its version at module scope and writes it from apply(), so after update → off → on it reported 1.0.0 while the reply said `live`. Three changes: - The toggle reply applies activationAfterReplace, the verdict the /installed listing already gave. The reply and a refresh of the listing told different stories about the same moment. - `restart` is true when enabling a plugin this process replaced while its host half was live. - setPluginEnabled no longer clears the replaced-while-live flag after a successful hot mount. Its comment said such a mount "imported the module as it is on disk NOW"; that is false exactly when the flag is set, since the flag is only set when this process already evaluated that URL, and MarketHotTree.import is a plain super.import that busts no cache. The flows case that asserted the notice clears after an off-and-on encoded that unmeasured belief. It is inverted, not deleted, with the measurement cited. 1650 unit tests; the full e2e suite passes on a real host, 9 files / 44 tests, including the new case and the restart that follows it (2.0.0, live). |
||
|
|
6073eb698d |
fix(update): stop rejecting every update on dsh 0.1.7 over the in-box bundle's patch list (#688) (#689)
Reported by @eddiedon with the three target manifests checked against the registry — each declares `dsh.bundle.patch: "./cordis.patch.yml"`, so the message "bundle declares no dsh.bundle.patch" was not about them. It was about `@deepseek-ai/dsh-web-app@0.1.7-alpha.1`, whose patch is a LIST (#676), and the update route's pre-boot trial reported only the first error's MESSAGE, dropping the layer name. Every plugin update on that host was therefore refused and rolled back, blaming whichever package the user had just updated. Two things, on top of #686's list handling in the analyzer: - The trial's composition now parses EVERY declared file into the layer's patches, not the first. #686 collected the entry ids from all files but `layers[].patches` — what composeLayers actually replays — still read one, so a duplicate id or an orphan in a preset file was invisible to the trial. Caught by the new test's row assertion, which failed on #686 alone. - The update route's failure text names the layer: `bundle @deepseek-ai/dsh-web-app: …` instead of a bare message that reads as an accusation of the wrong package. The regression test models the in-box bundle where the real loader gets it — under the dsh installation, not the profile, whose copy is a stale shadow the trial ignores (an earlier draft put it in the profile and passed vacuously). Reverting the list handling turns it red. 1651 tests. |
||
|
|
a15fa5ab7d |
ci: pin the e2e host's cordis plugins, which stopped booting (#684) (#690)
* ci: pin the e2e host's cordis plugins, which stopped booting (#684) Since 2026-09-22 every web-e2e job — main and every PR — failed before the market was reached: dsh: user patch-layer watching requires the Cordis HMR service Reported by @liuwenji007 with the timeline. The CLI was pinned (`npm install -g @deepseek-ai/dsh@<matrix>`), but its cordis plugins are caret ranges, so a fresh install took whatever was newest. Bisected on a fresh dsh@0.1.0-rc.8 install by swapping one package at a time and booting: - cordis-plugin-loader >= 1.0.4: the HMR service is never registered, which is the error above; - cordis-plugin-hmr >= 1.0.18: fails later, on hmr.registerConfig, against these CLIs' dsh-app-boot; - loader 1.0.2 + hmr 1.0.16 with everything else newest: boots, on both matrix versions. Without the pin 0.1.2-alpha.2 crashes the same way. A global install ignores npm `overrides` (root project only), so the host is now installed as the dependency of a throwaway project that carries them, and its bin directory goes on PATH. The full e2e suite passes locally against it: 9 files, 43 tests. Nothing in the market changes. Remove the overrides once the pinned CLIs resolve to a set that boots again; the job going green without them is the signal. * ci: install the pinned e2e host with pnpm, not npm npm resolved the dsh@0.1.0-rc.8 tree under the two overrides in 403s locally and 15 minutes on the ubuntu runner (0.1.2-alpha.2 was quick, so it is arborist backtracking on that tree, not the network). The Windows job was still inside the install step when this was written. pnpm with node-linker=hoisted — the same tree shape as the global npm install this replaces — installs the same versions (loader 1.0.2, hmr 1.0.16) in about 8s, on pnpm 10 as CI uses. Measured against it: both matrix versions boot, and the full e2e suite passes, 9 files and 43 tests. |
||
|
|
a10f17a2d9 |
fix(check): accept a bundle that declares several patch files (#676) (#686)
Reported by @KannaKuron against dsh 0.1.7-alpha.1, with the official
manifest as evidence. `@deepseek-ai/dsh-web-app@0.1.7-alpha.1` declares
"patch": ["./cordis.patch.yml", "./presets/standard.patch.yml",
"./presets/ptc.patch.yml", "./presets/minimal.patch.yml",
"./presets/cordis.patch.yml"]
— verified straight from the registry — and the official headless template
includes that bundle. The analyzer required a string, so the DEFAULT
profile layout was reported as "the profile will fail to boot" while
`dsh --profile web --dump-config` composed it with exit 0. A diagnostic
that calls a working profile broken is worse than no diagnostic: the
reporter saw users delete an official bundle on its advice.
Every declared file now contributes entries, not just the first — a patch
list is a list because the composer applies all of them, and parsing one
would leave the other files' rows looking like unknown ids downstream.
The report's SECOND finding is NOT fixed here, deliberately. It asks for a
user-patch row naming an install-level package to stop being an error,
citing `resolveBundleDir`'s "installation anchor first" contract. That
contract is for BUNDLES. A user patch's entries are imported through the
root Include, which resolves bare specifiers against `ctx.baseUrl` — the
PROFILE directory — unless the host passes `bareModuleBaseUrl`, and the dsh
CLI's `boot()` call does not pass it (read in
@deepseek-ai/dsh-app-boot 0.1.0-rc.8 and the CLI's profile-boot bundle).
So the existing #205 case is right and its test stays: a package only the
installation has is one the profile's Loader cannot see. Answered on the
issue with the source reading rather than silently ignored.
1650 tests. Reverting the list handling turns two of them red.
|
||
|
|
eaf57d79a3 |
release 1.57.0: the market renders on dsh 0.1.7-alpha.1 again
- Every market page crashed with React #130 on host 0.1.7-alpha.1 (#671, #673, reported with the component stack and the host's export list; fixed by @liuwenji007 in #681). That release renamed the ui-primitives icons from a size suffix to a weight one — IconSearchOutline16 became IconSearchOutlineRegular (1px) and …Medium (1.3px) — and dropped the old names outright, so all 17 icons this package imported were undefined and the first one rendered threw. The fix resolves each glyph by name at call time: the size-neutral name first, the pre-0.1.7 name second, and an empty glyph when a host has neither. A missing icon is now a cosmetic gap that cannot blank the page again. The alias table is checked against a fixture of the real 0.1.7 export list, so the table cannot agree with a test that is wrong in the same way — a corrupted alias name turns it red. `latest` is still 0.1.5-rc.2 and exports only the old names, which is why both spellings have to stay. |
||
|
|
36b8c23e0c |
release 1.56.0: security — a loopback Host is what stops DNS rebinding
- Every mutating /dsh-market/* route, and the restart and log-export fences, decided trust by comparing Origin with Host (#678, reported by @Ho-J with a working repro). A DNS-rebinding page defeats that by construction: served from evil.com, that name resolves to 127.0.0.1, so the browser connects to the loopback listener while sending BOTH headers as evil.com — the equality holds for the attacker, and the page is same-origin with its target, so it can read responses and drive every route, including installing a plugin. Measured before the fix: sameOrigin({ host: 'evil.com', origin: 'http://evil.com' }) → true Host is the one header the attack cannot forge, so it now has to name a loopback authority (127.0.0.1, localhost, [::1]; localhost.evil.com is a subdomain and does not match). An ABSENT Host is still allowed, because browsers always send one — a request without it is not from a page — and the Desktop build's proxy strips headers before forwarding (#648). - The installed-groups view is tightened (#668, by @bulingbuling688): group rename and add-member moved into modals, and the layout with them. Upgrade promptly if your dsh web is reachable by anything other than the machine it runs on; on a loopback-only setup the practical exposure was a page the user had open. |
||
|
|
9be13bf45f |
fix(security): require a loopback Host, which is what stops DNS rebinding (#678) (#682)
Reported by @Ho-J with a working repro and the correct diagnosis. The three fences — sameOrigin() and restart.ts's trustedRestartRequest() / trustedDownloadRequest() — all decided trust by comparing Origin with Host. A DNS-rebinding page defeats that completely: it is served from evil.com, that name resolves to 127.0.0.1, and the browser then connects to the loopback listener while sending `Origin: http://evil.com` AND `Host: evil.com`. The equality holds FOR THE ATTACKER, the peer address is loopback (so restart.ts's address check passes too), and the page is same-origin with its own target — it can read responses and drive every mutating route. Measured before the fix: sameOrigin({ host: 'evil.com', origin: 'http://evil.com' }) → true Host is the one header the attack cannot forge, so it is what has to name a loopback authority: 127.0.0.1, localhost, or [::1], with the port dropped and `localhost.evil.com` not matching. An ABSENT Host is still allowed, deliberately: every browser sends Host, so a request without one is not from a page, and the Desktop build's proxy strips headers before forwarding (#648). A rebinding page can never reach that branch. This is not a regression from #648 — that change concerned requests with no Origin, and the rebinding request carries one. But #648's PR body called the same-origin check "the CSRF case", and this is the case it was not covering. 1634 tests. Reverting the Host check turns two of them red. |
||
|
|
981ce6e0fe |
release 1.55.0: build approvals on every host, and badges that stop lying
- "Allow build scripts and retry" now writes a key pnpm accepts for a GitLab, Bitbucket or self-hosted install (#665, by @po-et). It wrote the bare package name, which is the one form pnpm does NOT match (#68/#69), so the button looked like it worked, the YAML gained a line, and the retry failed identically. Only reachable since #637 stopped these installs from being replaced by a same-named npm package first. - The Favorites tab loads host requirements for its cards (#669, by @liuwenji007). It reuses the Discover card, whose badge read "Reading host requirement…" forever there, because only Discover ever triggered the fetch. And the badge is quiet unless it has something to say (#666): a card that meets its requirement, or cannot be checked, no longer carries a mark. - The host package manager's env is documented as REPLACING the process env rather than filling its gaps (#653, by @Yur0918) — deliberately the opposite of the GIT_*/PNPM_CONFIG_* rule, because PATH is a capability (where node is), not a preference. |
||
|
|
35d06ee19f |
release 1.54.0: installs that would break the host, and uninstalls that lied
- A fresh install is now refused when the release itself declares it needs a NEWER host (#588, by @Yur0918 in #560). The update route has had this guard since #404/#473; installs did not, so the market would happily put a version into the profile that breaks `dsh web` on the next boot. Only a declaration that was READ and is not SATISFIED stops anything — undeclared, unreadable and an unknown host version all pass, because the absence of a claim is not a verdict. Both directions are covered by tests: treating `unknown` as incompatible fails two, never refusing fails two. - Uninstalling a plugin whose native addon is declared in `optionalDependencies` now asks for a restart instead of a page refresh (#441, by @liuwenji007 in #660). `SinglePlayer` ships `node-hid` exactly that way — `dependencies` is empty — so the uninstall was treated as plain JavaScript, and a refresh is precisely what cannot release a loaded `.node`. - A plugin installed from a URL — the shape a China-region install produces — now carries an identity, so its catalog card reads as installed (#432, fixed in #659). The spec names its own source and the market was answering "nothing"; it now derives the repo from the URL, while still NOT reading the package's own manifest, which for a fork names upstream (#580). - The repository now has a SECURITY.md and a private reporting channel (#603, in #661). A security report had nowhere to go but a public issue. |
||
|
|
0c9ba883ff |
docs: add SECURITY.md, and a channel that is actually private (#661)
Reported in #603, which could not find one: the repository had no SECURITY.md and private vulnerability reporting was not enabled, so a security report had nowhere to go but a public issue — where the reporter correctly withheld the request paths. GitHub's private vulnerability reporting is the channel. The file also states what this project is, because severity depends on it: the market holds no credentials, registers its routes with the host's web server rather than implementing a login, and runs shell commands against the user's own profile by design. |
||
|
|
777bcc17fe |
fix: read a URL install's identity off its URL, instead of nothing (#432) (#659)
A plugin installed from a URL had no identity, so its catalog card never read as installed. Reported as #432 with a China-region trigger, and the symptom is real; the proposed mechanism is not — this measures what the current dsh CLI actually leaves behind. $ dsh plugin --profile t1 add \ "https://gh-proxy.com/https://codeload.github.com/o/r/tar.gz/<sha>" { "dependencies": { "is-plain-obj": "https://gh-proxy.com/https://codeload.github.com/…" } } The spec keeps the https URL. There is no `file:` rewrite, no `bundled-plugins/`, and no sibling `url-<hash>.json` sidecar — the layout #432 reads. On that spec, before this commit: repoOfTarget(spec) → "sindresorhus/is-plain-obj" readInstalledRepoEvidence(spec) → { identities: [], hints: [] } `readInstalledRepoEvidence` returned nothing for any spec that names its own source, which was the right instinct for the WRONG half of the question: it must not resolve such a spec through the package's own manifest (#580 — a fork's manifest names upstream), but it must not resolve it to nothing either, because the spec IS the identity. The existing #544/#548 test asserted the empty list as a proxy for "does not read the manifest". It now asserts the sharper thing: for `github:myfork/dsh-plug` the identity is `myfork/dsh-plug` and never `upstream/dsh-plug`. Reverting the two-line behaviour turns it and the new proxy-URL case red. 1614 tests. |
||
|
|
798867ea12 |
release 1.52.0: the Desktop build can change things again
- Every mutating market route answered 403 "untrusted origin" in the Desktop build (#648, reported by @mimidor with the root cause traced on both sides, fixed here). Its proxy strips `origin`, `host`, `cookie` and `sec-fetch-site` before forwarding to the in-process host and injects a cookie only that host can sign, so "no Origin + authenticated cookie" is the trusted shape there — and `sameOrigin()` refused any request whose Origin was absent. Install, update, uninstall, restore and snapshots were all unusable. A browser sends Origin on every POST, so an absent one means the caller is not a page; and a process client can forge one anyway, so refusing the absence was never the protection it read as. A cross-site page, `Origin: null` and an empty Origin are all still refused. - A dependency library stops reading as a broken plugin (#634, by @po-et in #641): pnpm's auto-install-peers writes a plugin's peers into the profile manifest, so a native binding looked exactly like a plugin the user chose and a plugin with no dsh surface of its own that another installed plugin declares is now labelled "{0}'s library" instead. A real plugin that happens to be a dependency of another stays a plugin. - Typing in the market's search boxes no longer re-renders the whole list per keystroke (#549, by @booooodv). The input keeps its own draft — so characters still appear immediately — and commits after a 250 ms pause, on Enter, on blur, or at once when cleared. IME composition is excluded from all of it, including the Enter that confirms a candidate. - Discovery cards can show the catalog's npm latest version on the byline, when the catalog provides one (#348, by @liuwenji007 in #515). - Release-notes dialogs scroll, strip pasted HTML images, and render links, fences and quotes (#547); CJK and bilingual strings follow the active UI language (#534); the market's nav entry wears the block mark (#583); the host profile comes from the launcher rather than argv alone (#639, by @po-et in #644). |
||
|
|
722eaa7c06 |
fix: allow a POST that carries no Origin at all, so the Desktop build can mutate (#648) (#651)
Reported by @mimidor with the root cause traced through both sides. The Desktop app's proxy (forwardWebRequest) strips `origin`, `host`, `cookie` and `sec-fetch-site` before forwarding to the in-process host, and injects a cookie only that host can sign. `sameOrigin()` refused any request whose Origin was absent, so EVERY mutating route answered 403 `untrusted origin` in the Desktop build — install, update, uninstall, restore, snapshots. The check exists to stop another web page from driving this loopback API, and the Fetch spec has browsers send `Origin` on every POST, same-origin included. A request that arrives without it therefore did not come from a page — and a non-browser client can set any Origin it likes, so refusing the absence was never the protection it read as. What has to survive, and does: - a cross-site page is still refused (its Origin is present and different); - `Origin: null` from a sandboxed iframe or a data: document is still refused — present-but-unparseable is not the same statement as absent; - an EMPTY Origin is still refused, for the same reason; - a present Origin with no Host to compare against is still refused. Two tests are INVERTED rather than deleted, so the old rule cannot come back by accident: the unit case that asserted "a mutating POST must carry Origin", and an `it.each` over every mutating route. Reverting the one-line behaviour turns seven of them red. 1519 tests. |
||
|
|
d675534a89 |
release 1.50.0: an entry is not its package's name
The through-line is identity: a plugin's name is not its identity, and the market had been treating the loader entry's name as one. - A bundle patch whose row names a SUBPATH can be enabled again (#646, from @xiaohan13's report, in #647). `dsh-chat-import`, `meow-memory` and `dsh-context` name themselves, so `name === packageName` held for years; `aegis` mounts `aegis/extensions/dsh/index.js` and every site comparing the two concluded the plugin had no entry. Three sites restated the rule independently — verify, themes, routes — and now one module holds it. The two user-visible symptoms: "no bundle patch … nothing to hot-mount" for a package that plainly has one (because hotMount read only the package-root cordis.patch.yml while profile.ts resolved the manifest's declared path), and a restart demanded for a plugin that was already running. - The same shape, for the disable side (#619, by @cuddly-guacamole in #620): a subpath entry, and a CARRIER bundle whose rows name another package entirely. The second has no name relation to fall back on, so it is recognised by the entry id its own patch inserts. - An update to a floating git install now re-resolves instead of no-op'ing (#562, by @po-et in #564). `pnpm add` with a target byte-identical to the specifier already in the manifest answers "Lockfile is up to date, resolution step is skipped" — measured on 12.4.1 — so the install never moved. `pnpm update <name>` re-resolves inside the same specifier, and keeps the spec floating (verified on 10, 11 and 12). - A git install rolls back to the commit it came from, not just GitHub ones (#632, by @po-et in #636). Two packages of one monorepo resolve from the same remote and differ only by pnpm's `path:` selector; when the spec names no subpath and several entries share that remote there is no answer rather than the first sibling's commit — which would have pinned the WRONG package on rollback. - The market's settings card comes back in Desktop (#516, by @bulingbuling688 in #552). The namespace is registered with an EMPTY schema: enough for the host to dispatch the card, and deliberately not enough to offer the restart setting, which the Desktop shell owns. Stored values are neither deleted nor read. Also: the local-match memo moved out of the client into the module both the host restore route and the client share (#592 follow-up, by @JINITAIMEI121 in #618) — a cache kept on one side of a shared implementation is how the two sides drift apart. Thanks to @xiaohan13 for the #646 report: root cause, all three call sites, a verification table and the edge cases. It was complete enough to implement from directly. |
||
|
|
aeede78238 |
fix: match a subpath loader entry to its package, in all three places (#646) (#647)
A bundle patch's row `name:` need not be the package name. `dsh-chat-import`, `meow-memory` and `dsh-context` all name themselves, so `name === packageName` held for years; `aegis` mounts `aegis/extensions/dsh/index.js` and every site that compared the two directly concluded the plugin had no entry at all. Reported by @xiaohan13 with the root cause and all three call sites. Three sites, one assumption, three independent copies of the rule: - `src/verify.ts` `liveIncludes` — prefix-matched, so this one already worked for subpaths; now it calls the shared rule instead of restating it. - `src/themes.ts` `ownsLoaderEntry` — fixed for the toggle by #620, which landed after 1.48.0 and named this same plugin. Folded into the shared rule so there is one implementation, not two. - `src/routes.ts` `liveNames` — did NOT work: the live set held only the raw entry name, so `liveNames().has('aegis')` was false. After a successful enable the route computed `restart = !liveAfter` and told the user to restart for a plugin that was already running. Now the subpath name's implied package joins the set. `src/entry-identity.ts` holds the rule once, with the `/` bound that keeps `toolshrink` from matching `toolshrink-extra`, and the inverse mapping that returns null rather than a guess for `./relative`, `/abs`, `file://` and `cordis:` names. The carrier case (#156, an entry that names ANOTHER package) is unaffected: it is recognised by the entry id its own patch inserts, and the two rules compose. Separately, `hotMount` read `node_modules/<pkg>/cordis.patch.yml` and nothing else, while `profile.ts` resolves the manifest's `dsh.bundle.patch` for every other purpose. For `aegis` — patch at `./extensions/dsh/` — the root file does not exist, so the enable path fell through to the `dsh.client` check and told the user their package had "no bundle patch … nothing to hot-mount". Both now resolve the patch path through `declaredBundlePatchFile`, with the root file kept as the fallback. Every assertion mutation-checked: reverting the patch resolution, the live set, or the shared rule turns its test red. 1484 tests. |
||
|
|
8083b416c1 |
release 1.49.0: pnpm 12, and windows that should not exist
Two of these are about a pnpm major the market had not been tested against, and two are about the Windows consoles a console-less host should never show the user. - The `--config` overrides the retries depend on are now carried as `PNPM_CONFIG_*` variables as well (#615, by @po-et in #626). pnpm 12's native CLI silently ignores some `--config.<key>` overrides in either spelling — `fetchTimeout` on 12.2.1/12.3.0/12.4.1, `auto-install-peers` on 12.4.1 — so those retries ran exactly like the first attempt. The argument stays for the versions that read it; the variable covers the ones that do not, and nothing is set on a run that carries neither. - The release-age bypass is spelled the way pnpm 12.3+ reads it (#600, by @po-et in #616). `--config.minimumReleaseAge=0` is ignored there WITHOUT an error, so the retry failed exactly like the first attempt. `--config.minimum-release-age=0` is honoured by pnpm 10, 11 and 12 — measured here on all three before merging. - The restart helper no longer hands the replacement a visible console (#624, by @po-et in #625). `-WindowStyle Hidden` could not help: it governs the window PowerShell itself would create, not the console the spawn allocated for a console program from a console-less parent. That window OWNS the replacement, so closing it takes the new host down — the same class as #530, one step nastier. - The unit lane no longer sees the developer's proxy (#627, by @po-et). A setup file strips those variables globally, which is why two contributors saw failures on their machines that CI could not reproduce: a suite that is green or red depending on who runs it. Also: - "Update all" skips disabled plugins (#537, by @axdlee in #642, landed with the bundle rebuild his PR needed). A plugin the user turned off should not be dragged into a batch that can migrate the whole profile and then fail on ITS stale peer declarations, taking everyone else's updates with it. - The restart banner counts restart-pending changes rather than every completed one (#558, by @JINITAIMEI121 in #570): a client-only plugin goes live on refresh, so it never belonged in that number. - The CI step that guards the committed bundle now says what to run (#643). Its old output was a diff of a 560KB generated file — nothing about it told the contributor the command, which is why four of them needed a person to say the same sentence. |
||
|
|
66692ceb0e |
ci: the artifact guard now says what to run (#643)
* ci: the artifact guard now says what to run `git diff --exit-code client/` output is a diff of a 560KB generated file: it shows WHAT changed and never what to do about it. Four contributors hit it in one month — #518, #563, #570, #538 — and each needed a person to tell them the same sentence. The step now fails with an annotation naming the command, on the file the contributor would have to touch. Verified both branches locally: a dirty client/ prints the message and exits 1, a clean one passes. This is the second half of #586. That commit fixed one reason the artifact could differ across machines (a doubled `src/` path segment surviving normalization) and its message already said the failure "names the artefact rather than the build environment that produced it" — without doing anything about the naming. Now it does. * fix(ci): run the artifact guard under bash, not PowerShell The step failed on windows-latest with a ParserError: `run:` defaults to PowerShell there, and `if ! cmd` is a syntax error rather than a test. The step that exists to explain a failure died with a syntax error instead of explaining one — which is the same mistake one level up: a gate nobody can act on. shell: bash, which the Windows runners have via Git Bash. Caught by CI on the PR itself, which is the cheapest possible place for it. |
||
|
|
d8c31e2a17 |
fix: skip disabled plugins in update-all, and rebuild the bundle (#537) (#642)
* fix: skip disabled plugins in update-all * test: cover disabled plugins in update-all * chore: rebuild client/client.js for the disabled-plugin batch skip @axdlee's #538 changed src/client/MarketSection.tsx and did not rebuild the committed bundle, so the artifact guard failed on both platform jobs — the fourth contributor to hit that this month (#563, #570, and #518 before them). His change and his test are kept verbatim, rebased onto current main (his branch predates the removal of client/client.js.map, which turned the rebase into a one-off modify/delete). Co-authored-by: axdlee <axdlee@users.noreply.github.com> --------- Co-authored-by: Sheldon.li <xdlee110@gmail.com> Co-authored-by: axdlee <axdlee@users.noreply.github.com> |
||
|
|
9ecd3bc69b |
test: pin every proxy variable the #148 case reads, not just the one it sets (#613)
@po-et hit this while working on #593: the case exports HTTPS_PROXY and asserts both npm_config_https_proxy and npm_config_proxy, but restores only HTTPS_PROXY and clears nothing else. On a machine that exports HTTP_PROXY — common, and true of theirs — proxyEnvForPnpm derives npm_config_proxy from the real value and the assertion fails for a reason that has nothing to do with the code under test. A test that passes only on machines shaped like its author's is worse than no test: it fails on someone else's contribution, and the failure points at their change. Reproduced before fixing (`HTTP_PROXY=… npx vitest run tests/dsh-cli.spec.ts` → 1 failed), and both with and without the variable after (27 passed). Found by @po-et, who reported it in #593 and deliberately did not fix it there — it is not that PR's business. |
||
|
|
6e4b8041a6 |
release 1.48.0: failures that described themselves wrong
Every fix here is about the market telling a user something untrue about their own machine, or doing work that could not possibly succeed. - A fresh install no longer lands one version behind. A bare registry name hands the version choice to pnpm, and pnpm 11's fresh-release hold makes that choice silently: a release younger than minimumReleaseAge is skipped for the newest mature one, exit 0, and the `^0.x` written into the profile never floats to the next minor (#594, fixed by @po-et in #621). A fresh npm install is now pinned to the registry's latest. Where the profile sets minimumReleaseAge ON PURPOSE, that policy is kept: the pin fails, the market falls back to the bare name, and says so — unlike an update, where the user explicitly asked for the newest and the one-shot bypass serves their intent rather than overriding the profile's. - The release-age bypass now works on pnpm 12.3+. `--config.minimumReleaseAge=0` is ignored there without an error — the retry ran without the bypass and failed exactly like the first attempt (#600, by @po-et in #616). Spelled `--config.minimum-release-age=0`, which pnpm 10, 11 and 12 all honour. - An update that cannot replace files the running host holds open no longer tries to roll back through the same rename. pnpm retried for about three minutes and the user was then told their profile might be broken (#608, by @po-et in #617). package.json and pnpm-lock.yaml are restored and the previous build's entry is CHECKED rather than assumed — pnpm clears as much of the target directory as it can before retrying, so content beside the locked file may already be gone. - ...and the message that failure shows no longer promises "what was already installed is intact" (#630). That promise was false, and @po-et was right to leave the wording to me: it was mine, written when the route still assumed the previous build survived. - git can no longer block an install on a credential prompt nobody can answer (#587, by @po-et in #593). It reads CI on pnpm's behalf; git does not, and its prompt opens the controlling terminal rather than stdin — the reporter caught a `git.exe` alive for eight minutes with 0.05s of CPU. - Flat Desktop hosts are recognised (#553, by @bulingbuling688 in #574): the layout with the shell manifest at `resources/app/package.json` and split runtime packages beside it, which the locator could not see at all. A Desktop version is reported only when the shell and four in-box witnesses agree; anything less retains a confirmed directory with `unknown`. - A host shell can render the market's own panel (#602, by @hairyf from the Tauri desktop). `ctx.reflect.get('market').render()` returns the panel as an element for the shell's own container, and `setSettingsVisible(false)` retracts the duplicate settings entry. Also a new `GET /dsh-market/api/v1/updates/summary` for a badge: `updatable`, with `checked` as the denominator so "nothing to update" and "nothing was looked at" are different answers. Thanks to @po-et, @bulingbuling688, @hairyf and everyone else who reported these with enough detail to act on. |
||
|
|
53f793e775 |
feat: let a host render the market's own panel (#602) (#633)
The Tauri desktop shell's answer to the last round was that status queries alone do not help them — their purpose is to consume the market's PANEL, which they render inside their own container alongside their own chrome. That is a smaller thing than the slot inversion I declined, and it is the fallback their issue had already proposed. `market.render(props?)` returns the market's panel, error boundary included, as a React element. Same page and same React instance: this package's client bundle resolves react through the host's module table, so the element mounts anywhere in that tree. The builder moved into `src/client/market-element.ts`, taking its dependencies as arguments rather than closing over the cordis context, for two reasons: - the settings section and `render()` must not drift. They pass different `preferredSubsectionId`s and the same everything else, and a mix-up would show up as a panel opening on the wrong tab in one of the two places, on somebody's machine; - wiring that only a running host can reveal is wiring a test cannot check. The spec asserts which translate function, locale, theme store and log exporter reach the panel, and that the recovery panel's log button calls the exporter it was handed. `ctx.provide` and `ctx.reflect.provide` are the same call — cordis's Service.provide delegates to the reflect layer — so the reporter's own idiom reaches this service. Documented in UPDATE-API-V1.md. Deliberately still not done: host-fillable slots inside the market. One host asking does not yet say what the second one would need. |
||
|
|
355c1d4894 |
fix: stop promising the locked build is intact when it may not be (#608) (#630)
@po-et noticed this while fixing #608, and left the wording to me because it is not that PR's business. He is right that it is false. The message said "what was already installed is intact". It is not always: pnpm's renameOverwrite clears as much of the target directory as it can BEFORE retrying the rename, so files beside the one it cannot remove may already be deleted. He measured it on macOS with only the directory inode locked — `perf/*.js` gone, `index.js` survived. Two things made that worse than an ordinary imprecision. It was a promise, in the one message a user reads when something has gone wrong, and the route now checks whether the entry survived instead of assuming (#608) — so the classifier and the route were telling the user two different things about the same event. The wording now names the risk and points at the check the route actually performs. The assertion is inverted rather than deleted: it fails if the promise comes back. Deliberately free of the word 更新/"update": #441 established that this message must not describe itself as an update, because a reinstall meets it too. |
||
|
|
ff9eb25b3d |
feat: an update-count aggregate, and a host control for the settings entry (#602) (#629)
Two of the four things the Tauri desktop shell asked for, chosen because
they are the two that block an integration today and neither invents an
architecture.
1. GET /dsh-market/api/v1/updates/summary — the aggregate a badge needs.
The single-package endpoint requires `name`, so a host showing "3
updates" had to enumerate the profile and call it once per plugin, or
read the market's private listing, which has no schema and no
capability bit. `checked` is the denominator, so "nothing to update"
and "nothing was looked at" are different answers; `packages` carries
the same objects the single endpoint returns, so one parser serves both.
Both endpoints now build their inputs through one helper
(updateCheckInputs), which is what makes the aggregate and the
single check unable to disagree.
2. ctx.provide('market', { version, setSettingsVisible, settingsVisible })
— for a host that renders the market itself and does not want a
duplicate nav entry.
The mechanism had to be in-page: the client registers the section
synchronously during apply(), so it cannot consult the host's
environment, and routing it through the server would cost every user a
boot request for one host's preference. cordis's `provide` is already
used by the host's own client plugins, so this is the existing shape
rather than a new one.
The visibility rules are a small state machine (section-gate.ts)
because the orderings are the work: a host that hides before the slots
service arrives must not get a flash of the entry, a removal must not
be undone by a later "show", and hide-then-show must not produce two
entries. Verified by mutating the removal rule and watching exactly the
two tests that describe it fail.
Not here: the injectable UI slots (`market.title`/`tabs`/`body`). That
inverts this package from slot consumer to slot provider, and designing it
for one host before a second has asked is how an interface ends up usable
by nobody else. Worth a conversation once someone actually needs it.
|
||
|
|
a8401c46fb |
release 1.47.0: a prompt that was never true, and a list that got slow
Two user-facing fixes lead, both reported by people who could see exactly what was wrong and could not do anything about it. - "Update available" that could never be satisfied (#597, by @JINITAIMEI121). An annotated tag is advertised as two refs — the tag object and the commit it peels to — and the market compared the wrong one. pnpm records the peeled commit, so the installed sha never matched and the prompt never went away, however many times it was accepted. - The catalog no longer costs 300ms a keystroke (#589, by @po-et). Catalog matching for `link:`/`file:` installs was uncached while the version-spec path has been memoized since #262, and it runs once per rendered card — measured at ~300ms per repaint with one local dependency against the 3627-entry catalog. Also: - The update-notes dialog stops reading a same-named stranger's repository (#598, by @JINITAIMEI121). It took the first catalog entry matching by name, so a plugin whose notes exist was told there were none. - A monorepo subpackage reads as installed at last (#605, by @junmingcode). The catalog states `owner/repo#path:/pkg` while an npm-installed manifest usually states the bare `owner/repo`, and the asymmetry was being read as a source conflict. - Hot-mount rows resolve against the profile rather than the loader's own location (#550, by @sampx), so a closure-hosted loader can reach the package it just installed instead of answering "restart required" — and a failed mount no longer leaves a file behind for every later boot to re-import and re-fail. - "Update all" appears when one plugin is updatable (#555, by @po-et). It was gated at two, which left the header offering "ignore all update reminders" with nothing to actually click. - dsh desktop joins the Friends list (by @MochiNek0), and the #385 compat probe accepts the lock shapes current pnpm writes (by @liuwenji007), which had been failing unrelated PRs. |
||
|
|
1860262d29 |
release 1.46.1: a failed enable can no longer loop the host at boot
Released on its own, because the failure mode costs the user their profile until they edit YAML by hand. Enabling a plugin that crashes deterministically on import persisted "enabled" anyway — into cordis.patch.yml, into the market's state.json, and into the running disable set. The loader re-applied it on every start: @JINITAIMEI121's reporter measured 24 restarts before restoring the disable by hand. The fix keeps their asymmetry, which is the hard part of it: a failed ENABLE must not flip the durable layer, a failed DISABLE must — the user asked for OFF, and a failed unmount leaves the plugin live only for this session. Their patch-layer gate is in verbatim (#584, co-authored); this adds the market's own store and its in-memory set, which matters twice: the two persisted views were disagreeing, so which one won at the next boot depended on load order, and a CLIENT-ONLY plugin has no bundle rows at all — its only durable state is state.json, so for that plugin kind the loop was untouched. Also here: a doubled `src/src/` path segment in the committed bundle's CSS ids is normalized away. It made two contributor PRs unmergeable with a one-line diff neither of them wrote, and a failure message that named the artefact rather than the build environment that produced it. |
||
|
|
2945592e86 |
fix: normalize a doubled path segment in the committed bundle's CSS ids (#586)
Two contributor PRs (#563, #570) were red on `git diff --exit-code client/` with a one-line difference neither of them wrote: -//#region \0dsh-css:src/src/client/Market.module.css.mjs +//#region \0dsh-css:src/client/Market.module.css.mjs Their build emitted a doubled `src/` segment. normalize-client-banner exists to make these ids machine-independent, and it stripped absolute prefixes correctly — but its prefix match was lazy, so it anchored on the FIRST `src/` and walked past the duplicate. Greedy anchors on the last one, which is the canonical tail. The cost of the gap was not a wrong bundle: it was a contributor staring at a failure that says only "the artefact does not match", with nothing pointing at their build environment as the variable. This file exists because environments differ; a rule that only handles the difference it was written for is half a rule. Tested by lifting the live rule out of the script by source rather than copying it — a copy keeps passing after the original changes, which is the exact failure this guards. Verified by reverting the rule to lazy and watching two of the five cases go red, including idempotence. |
||
|
|
310ac4a58c |
fix: a failed enable leaves every view of the state as it was (#575) (#585)
* fix(routes): a failed enable must not flip the durable patch layer (#575) The toggle route wrote the patch rows unconditionally, so a failed enable (deterministic import crash) flipped the durable layer to enabled and turned a transient in-session error into a boot crash loop. Gate the enable-direction patch write on ok; disables keep their unconditional write (a failed unmount still means the user asked for OFF). * fix(routes): a failed enable must not flip the durable patch layer (#575) The toggle route wrote the patch rows unconditionally, so a failed enable (deterministic import crash) flipped the durable layer to enabled and turned a transient in-session error into a boot crash loop. Gate the enable-direction patch write on ok; disables keep their unconditional write (a failed unmount still means the user asked for OFF). * fix(routes): a failed enable must not flip the durable patch layer (#575) The toggle route wrote the patch rows unconditionally, so a failed enable (deterministic import crash) flipped the durable layer to enabled and turned a transient in-session error into a boot crash loop. Gate the enable-direction patch write on ok; disables keep their unconditional write (a failed unmount still means the user asked for OFF). * test: pin the toggle patch-layer gate (#575) Bundle-plugin flow: a failed enable leaves the user patch untouched (still disabled) instead of flipping the durable layer into a boot crash loop. * fix: a failed enable leaves every view of the state as it was (#575) Completes @JINITAIMEI121's fix in #584, whose diagnosis and asymmetry are kept verbatim: a failed ENABLE must not flip the durable layer, a failed DISABLE must — the user asked for OFF, and a failed unmount leaves the plugin live only for this session. Their patch-layer gate closed the hole in cordis.patch.yml. Two of the three views the report described were still flipped: setPluginEnabled recorded the choice BEFORE attempting the mount and persisted state.json whatever happened, so after a failed enable the market's own store and its in-memory set both said "enabled" while the patch layer said "disabled". That left two problems. The persisted views disagreed, so which one won at the next boot depended on load order — harder to diagnose than the original bug. And a CLIENT-ONLY plugin has no bundle rows at all, so `patchRows` is empty, the patch gate never runs, and state.json is its only durable state: for that plugin kind the crash loop was untouched. The reporter measured 24 restarts. The in-memory set is now restored before persisting, so the reply, the store and the patch layer tell one story. Tests: the bundle-plugin case also asserts the market's own answer, and a new client-only case covers the path the patch gate cannot reach. Both failed before this change with `expected [] to include …`. Co-authored-by: JINITAIMEI121 <JINITAIMEI121@users.noreply.github.com> --------- Co-authored-by: eeeeeeaaaa12 <1303576427@qq.com> Co-authored-by: JINITAIMEI121 <JINITAIMEI121@users.noreply.github.com> |
||
|
|
1ecdd8eaa0 |
release 1.46.0: stop treating a plugin's name as its identity
Same-named catalog entries are legitimate by design, and three bugs in a row came from forgetting it. This release closes the last of them and fixes the fix. - An npm-installed plugin is matched to the right catalog entry (#544, by @QinYupan's diagnosis). Two entries named dsh-mermaid, an ordinary npm install, and Discover kept offering Install on a plugin that was running: the evidence reader bailed out for anything not link:/file:, so the client fell back to the name, found two candidates, and matched NEITHER. The installed manifest's `repository` is read for registry specs now, which is the one fact that says which of the two is on disk. - ...and a spec that names its own source is never second-guessed (#548, boundary by @bulingbuling688). My first version of the fix above read the manifest for EVERY spec kind, which broke forks: a plugin installed as `github:myfork/plugin` usually still declares the upstream repository, and the upstream's card then read as installed. Their PR had drawn the line correctly — registry specs yes, explicit Git/URL sources no — and this adopts it. - Generations-installed plugins report their newer release (#497, by @po-et). The desktop host installs through `link:` into `.generations/live/`, which checkUpdates read as a developer checkout and answered "no update" for — including for the market itself. Reported, never offered: @lin-1259 established from the host's own code that its reconciler restores the generation at startup, so an applied update would appear to work and silently revert. - A host requirement declared as `dsh.engines.dsh` is finally read (#577, by @JINITAIMEI121). The market only read the top-level `engines.dsh`; measured across 120 sampled catalog packages, 2 of the 11 that declare a requirement use the nested position only, and for those authors the install-time preflight and the Discover filter did not exist. - ERR_PNPM_NO_MATCHING_VERSION gets its own explanation (#569, by @JINITAIMEI121), split by whether the package is a host peer — the runtime provides those and the market retries automatically, while an ordinary dependency's missing version needs someone to act. It shared fetch-404's wording before, which told users to delete a ghost entry that was not there. - The category row no longer forces a second commit when the header pins (#518, by @liuwenji007), which is the hitch behind the settings dialog stuttering after a tab collapse. - The repository stops carrying a copy of the plugin catalog (#545, by @Icstick). It was 2.1MB of 11-day-old data this repository does not own, gating merges while looking enough like the source of truth that people tried to add plugins to it. The merge gate now validates the live catalog, and skips rather than fails when the origin is unreachable. |
||
|
|
f334456056 |
fix: do not read the manifest for a spec that names its own source (#548) (#580)
The #544 fix I merged in #559 widened readInstalledRepoEvidence to every spec kind. @bulingbuling688 had drawn the boundary differently in #548 — registry specs only, explicit Git/URL sources excluded — and that boundary is the correct one. Mine had a hole. Measured: a fork installed as `github:myfork/dsh-plug` whose package.json still declares `upstream/dsh-plug` — which is the normal state of a fork, since almost nobody edits that field. depRepoIds unions the spec-derived ids with the manifest identities, so the upstream id entered the set, sameSourceConflict stopped excluding the upstream's card, and the UPSTREAM card read as installed. A weaker signal outvoting a definite one: #485's mistake, reintroduced three days after closing it. A spec that names its own source is the authority on it. Local specs still read the manifest (that is where #141 and #429 get their identity), registry specs now do too (that is #544), and `github:`, `git+`, ssh, scp, codeload and any http(s) spec do not. Tests on both sides: the server returns no evidence for a fork spec, a Release archive, and a raw git+https remote; and the client keeps matching the fork while leaving the upstream unmatched, so if evidence for a git spec ever reappears the consequence is pinned. |
||
|
|
f33c7fbe7d |
fix: read the repository of an ordinary npm install (#544) (#559)
@QinYupan installed dsh-mermaid from the market; the Discover card kept saying Install. Two same-named catalog entries exist (MrmoLabs with an npm field, AKS1st without), and the manifest of the installed package declares `repository: git+https://github.com/MrmoLabs/dsh-mermaid.git` — the one fact that says which of the two is on disk. readInstalledRepoEvidence bailed out for any spec that was not `link:`/`file:`, so an npm install carried no repo identity. The client then fell back to name matching, found two candidates, and matched NEITHER — the reporter's own diagnosis, confirmed against the code. The manifest's repository is read for every spec kind now. What stays local-only, deliberately: the git-origin hint (there is no checkout to read for an npm install) and the local source directory walk. Tests pin the exact reported shape: the MrmoLabs entry matches through manifest evidence, the AKS1st entry does not, entryForDep agrees, and — with no evidence, the pre-fix answer for an npm install — neither matches, which is the symptom as it looked from the card. |
||
|
|
88c8ce33ad |
chore: validate the live catalog, and stop committing a copy of it (#545) (#557)
* chore: validate the live catalog, and stop committing a copy of it (#545) @Icstick asked why `data/registry-snapshot.json` was eleven days behind the catalog and how to request a refresh. The answer turned out to be that nothing depends on it being fresh, and that the file's main effect was to mislead. What it was not: - Not what the market reads. The bundled fallback was removed on purpose (see loadRegistry: for a catalog, stale is not a degraded answer, it is a wrong one). A build-site.yml comment still called it "the plugin's runtime fallback"; that was left over. - Not shipped. `data/` is not in package.json `files`. - Not what the site serves. The site build downloads its own copy into a checkout it throws away. What it was: a 2.1MB file that gated merges on an 11-day-old copy of data this repository does not own — 2452 entries against 3408 live — while looking enough like the source of truth that people tried to add plugins to it. The CI guard refusing hand edits exists because that had already happened. So the gate now reads what users actually get: validate-registry fetches the live catalog, prefers a local file when one is present (offline runs still work by dropping one in), and SKIPS with a notice when the origin is unreachable — failing unrelated PRs on someone else's outage is how a gate gets ignored. The guard stays, retargeted: adding the file back is now the mistake it was always trying to prevent. One test read the snapshot as a corpus, cross-checking pluginSlug against the site builder's slugOf so two plugins can never share a comment thread. It now reads a committed corpus of every URL SHAPE the catalog publishes — 108 of them across 3408 entries, sampled with real URLs, 24KB instead of 2.1MB. Whether the catalog only ever publishes those shapes is gated where it belongs: against the live catalog, by validate-registry's E5. @Icstick's three plugins were already visible in the market; verified against the catalog package the market actually reads. * ci: let the catalog-copy guard tell an addition from a removal The guard fired on the PR that deletes the file it was written to protect: it matched any diff touching that path, and a removal is a diff. --diff-filter=AM narrows it to what it actually means — adding the file back, or editing it in place. |
||
|
|
1664caec99 |
release 1.45.1: the market stops gating itself on seams it never used
A single fix, released on its own because of what it does to OTHER plugins. `dsh.client.inject` is a hard gate — the host holds the client entry back until every listed seam exists. The market listed five and used three. One of the two dead entries, `@deepseek-ai/dsh-client-runtime`, is a seam dsh 0.1.2-rc.1 ships incompletely and which had already hard-failed another plugin's boot. @MarchLiu found (#554) that with the market enabled, dsh-context's /context command silently stopped registering — no host error, no console output, no failed boot entry — and isolated it by binary search to the market's entry alone. Both entries had been in the manifest since 0.1.0, never removed when the code that might have needed them was not written. This is not a 1.45.0 regression; it is as old as the plugin, and only visible on a host that does not ship those seams. rc.8 ships both, which is why the e2e lane never saw it. What the market can settle, it has: a seam it never used is a host version it refused to run on, bought for nothing. Whether that fully explains the other plugin's silence depends on how the host propagates a never-satisfied inject to later client modules, which is upstream. A test now keeps the declaration and its use next to each other, reading the client source rather than a list, so it fails both ways: a seam nothing uses, and a `ctx.` service nothing declares. |